From 5b3cd849929c56c1e97f4eccf6fac4fa7d9136c2 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 30 Jun 2026 17:34:29 +0100 Subject: [PATCH 1/2] totp template --- .github/workflows/publish-edge-totp.yml | 128 + CLAUDE.md | 4 +- README.md | 9 + edge-totp/.gitignore | 150 + edge-totp/AGENTS.md | 38 + edge-totp/CLAUDE.md | 110 + edge-totp/LICENSE | 201 ++ edge-totp/README.md | 78 + edge-totp/context/INDEX.md | 58 + edge-totp/context/architecture/flow.md | 93 + edge-totp/context/architecture/overview.md | 35 + .../architecture/storage-and-secrets.md | 83 + edge-totp/context/design/decisions.md | 102 + .../context/design/runtime-constraints.md | 60 + edge-totp/context/integration.md | 89 + edge-totp/context/security/threat-model.md | 103 + edge-totp/otp-app/.env.example | 202 ++ edge-totp/otp-app/package.json | 21 + edge-totp/otp-app/registry.json | 217 ++ edge-totp/otp-app/scripts/gen-ec-keypair.mjs | 47 + edge-totp/otp-app/src/challenge.ts | 80 + edge-totp/otp-app/src/config.ts | 97 + edge-totp/otp-app/src/enroll.ts | 131 + edge-totp/otp-app/src/index.ts | 545 ++++ edge-totp/otp-app/src/lib/base32.ts | 43 + edge-totp/otp-app/src/lib/cookies.ts | 26 + edge-totp/otp-app/src/lib/html.ts | 51 + edge-totp/otp-app/src/lib/jwt.ts | 187 ++ edge-totp/otp-app/src/lib/qr.ts | 11 + edge-totp/otp-app/src/lib/safeEqual.ts | 14 + edge-totp/otp-app/src/lib/totp.ts | 108 + edge-totp/otp-app/src/lib/validate.ts | 65 + edge-totp/otp-app/src/seed/kv.ts | 51 + edge-totp/otp-app/tests/unit/base32.test.ts | 58 + edge-totp/otp-app/tests/unit/jwt.test.ts | 210 ++ edge-totp/otp-app/tests/unit/totp.test.ts | 74 + edge-totp/otp-app/tests/unit/validate.test.ts | 66 + edge-totp/otp-app/tsconfig.json | 14 + edge-totp/otp-filter/.cargo/config.toml | 2 + edge-totp/otp-filter/.env.example | 61 + edge-totp/otp-filter/Cargo.lock | 650 ++++ edge-totp/otp-filter/Cargo.toml | 20 + edge-totp/otp-filter/package.json | 16 + edge-totp/otp-filter/registry.json | 49 + edge-totp/otp-filter/src/lib.rs | 258 ++ edge-totp/otp-filter/tests/filter.test.ts | 256 ++ edge-totp/package.json | 16 + edge-totp/pnpm-lock.yaml | 2783 +++++++++++++++++ edge-totp/pnpm-workspace.yaml | 15 + 49 files changed, 7784 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-edge-totp.yml create mode 100644 edge-totp/.gitignore create mode 100644 edge-totp/AGENTS.md create mode 100644 edge-totp/CLAUDE.md create mode 100644 edge-totp/LICENSE create mode 100644 edge-totp/README.md create mode 100644 edge-totp/context/INDEX.md create mode 100644 edge-totp/context/architecture/flow.md create mode 100644 edge-totp/context/architecture/overview.md create mode 100644 edge-totp/context/architecture/storage-and-secrets.md create mode 100644 edge-totp/context/design/decisions.md create mode 100644 edge-totp/context/design/runtime-constraints.md create mode 100644 edge-totp/context/integration.md create mode 100644 edge-totp/context/security/threat-model.md create mode 100644 edge-totp/otp-app/.env.example create mode 100644 edge-totp/otp-app/package.json create mode 100644 edge-totp/otp-app/registry.json create mode 100644 edge-totp/otp-app/scripts/gen-ec-keypair.mjs create mode 100644 edge-totp/otp-app/src/challenge.ts create mode 100644 edge-totp/otp-app/src/config.ts create mode 100644 edge-totp/otp-app/src/enroll.ts create mode 100644 edge-totp/otp-app/src/index.ts create mode 100644 edge-totp/otp-app/src/lib/base32.ts create mode 100644 edge-totp/otp-app/src/lib/cookies.ts create mode 100644 edge-totp/otp-app/src/lib/html.ts create mode 100644 edge-totp/otp-app/src/lib/jwt.ts create mode 100644 edge-totp/otp-app/src/lib/qr.ts create mode 100644 edge-totp/otp-app/src/lib/safeEqual.ts create mode 100644 edge-totp/otp-app/src/lib/totp.ts create mode 100644 edge-totp/otp-app/src/lib/validate.ts create mode 100644 edge-totp/otp-app/src/seed/kv.ts create mode 100644 edge-totp/otp-app/tests/unit/base32.test.ts create mode 100644 edge-totp/otp-app/tests/unit/jwt.test.ts create mode 100644 edge-totp/otp-app/tests/unit/totp.test.ts create mode 100644 edge-totp/otp-app/tests/unit/validate.test.ts create mode 100644 edge-totp/otp-app/tsconfig.json create mode 100755 edge-totp/otp-filter/.cargo/config.toml create mode 100644 edge-totp/otp-filter/.env.example create mode 100644 edge-totp/otp-filter/Cargo.lock create mode 100644 edge-totp/otp-filter/Cargo.toml create mode 100644 edge-totp/otp-filter/package.json create mode 100644 edge-totp/otp-filter/registry.json create mode 100644 edge-totp/otp-filter/src/lib.rs create mode 100644 edge-totp/otp-filter/tests/filter.test.ts create mode 100644 edge-totp/package.json create mode 100644 edge-totp/pnpm-lock.yaml create mode 100644 edge-totp/pnpm-workspace.yaml diff --git a/.github/workflows/publish-edge-totp.yml b/.github/workflows/publish-edge-totp.yml new file mode 100644 index 0000000..685c9d0 --- /dev/null +++ b/.github/workflows/publish-edge-totp.yml @@ -0,0 +1,128 @@ +name: Publish edge-totp + +on: + workflow_dispatch: + push: + branches: + - feature/saml-app + paths: + - 'edge-totp/**' + +jobs: + build_and_publish: + name: Build and publish edge-totp + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Import credentials from Vault + uses: hashicorp/vault-action@v3 + id: creds + with: + url: https://puppet-vault.gc.onl + token: ${{ secrets.VAULT_TOKEN }} + secrets: | + secret/project_fastedge/harbor-robot-account username | HARBOR_LOGIN ; + secret/project_fastedge/harbor-robot-account password | HARBOR_PASSWORD ; + secret/project_fastedge/gcore_api/preprod/token token | PREPROD_API_KEY ; + secret/project_fastedge/gcore_api/preprod/hostname url | PREPROD_API_URL ; + secret/project_fastedge/gcore_api/prod/token token | PROD_API_KEY ; + secret/project_fastedge/gcore_api/prod/hostname url | PROD_API_URL ; + + - name: Login to Harbor and pull compiler + uses: ./.github/setup-harbor-pull + with: + harbor_login: ${{ steps.creds.outputs.HARBOR_LOGIN }} + harbor_password: ${{ steps.creds.outputs.HARBOR_PASSWORD }} + + - name: Setup Node.js and install dependencies + uses: ./.github/setup-node + with: + working_directory: edge-totp + + # --- Unit tests (no wasm needed — fast gate before any build) --- + + - name: Unit tests + working-directory: edge-totp + run: pnpm -C otp-app test + + # --- TypeScript app --- + + - name: Build TS WASM + working-directory: edge-totp + run: pnpm -C otp-app build + + # --- Rust filter --- + + - name: Build Rust filter + uses: ./.github/build-rust + with: + rust_package: totp-filter + working_directory: edge-totp/otp-filter + output_path: edge-totp/otp-filter/wasm/totp-filter.wasm + + # --- Filter tests (require compiled wasm) --- + + - name: Filter tests + working-directory: edge-totp + run: pnpm -C otp-filter test + + # --- Deploy --- + # preprod: always (workflow_dispatch or push to feature/saml-app) + # prod: push to main only + + - name: Read totp-app registry + id: totp-app + run: | + echo "descr=$(jq -r '.description' edge-totp/otp-app/registry.json)" >> "$GITHUB_OUTPUT" + echo "params=$(jq -c '.params' edge-totp/otp-app/registry.json)" >> "$GITHUB_OUTPUT" + + - name: Deploy totp-app to preprod + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PREPROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PREPROD_API_URL }} + wasm_file: edge-totp/otp-app/dist/totp-app.wasm + template_name: totp-app + short_descr: ${{ steps.totp-app.outputs.descr }} + params: ${{ steps.totp-app.outputs.params }} + + - name: Deploy totp-app to prod + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PROD_API_URL }} + wasm_file: edge-totp/otp-app/dist/totp-app.wasm + template_name: totp-app + short_descr: ${{ steps.totp-app.outputs.descr }} + params: ${{ steps.totp-app.outputs.params }} + + - name: Read totp-filter registry + id: totp-filter + run: | + echo "descr=$(jq -r '.description' edge-totp/otp-filter/registry.json)" >> "$GITHUB_OUTPUT" + echo "params=$(jq -c '.params' edge-totp/otp-filter/registry.json)" >> "$GITHUB_OUTPUT" + + - name: Deploy totp-filter to preprod + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PREPROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PREPROD_API_URL }} + wasm_file: edge-totp/otp-filter/wasm/totp-filter.wasm + template_name: totp-filter + short_descr: ${{ steps.totp-filter.outputs.descr }} + params: ${{ steps.totp-filter.outputs.params }} + + - name: Deploy totp-filter to prod + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PROD_API_URL }} + wasm_file: edge-totp/otp-filter/wasm/totp-filter.wasm + template_name: totp-filter + short_descr: ${{ steps.totp-filter.outputs.descr }} + params: ${{ steps.totp-filter.outputs.params }} diff --git a/CLAUDE.md b/CLAUDE.md index 7e409c5..b518925 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ template to adapt it to their needs. |---|---| | `html2md/` | Proxy-WASM filter: converts HTML origin responses to Markdown on `Accept: text/markdown` | | `edge-sso/` | Multi-provider SSO bolt-on (Google, GitHub, Microsoft, Facebook, SAML) — three delivery variants: gate-only, cookie, header | +| `edge-totp/` | Edge TOTP MFA bolt-on — adds RFC 6238 two-factor auth in front of an existing login; HTTP app (otp-app) + Rust proxy-wasm enforcement filter (otp-filter) | ## Repo Structure @@ -27,7 +28,8 @@ FastEdge-templates/ ├── assets/ ← shared marketing assets (deploy buttons, etc.) ├── .github/workflows/ ← CI/CD: builds and publishes all templates to Gcore portal ├── html2md/ ← standalone Rust/WASM template -└── edge-sso/ ← standalone mixed Rust+TypeScript SSO template +├── edge-sso/ ← standalone mixed Rust+TypeScript SSO template +└── edge-totp/ ← standalone mixed Rust+TypeScript TOTP MFA template ``` ## Standalone Principle diff --git a/README.md b/README.md index 595d04d..6318418 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,15 @@ backend. Comes in three delivery variants: See [`edge-sso/README.md`](edge-sso/README.md) for details. +### edge-totp + +Edge TOTP MFA bolt-on — adds RFC 6238 two-factor authentication in front of a customer's +existing login without changing the backend. The customer's origin handles password +validation and hands off to the edge app for the OTP challenge, which issues a signed +session cookie that a paired CDN filter enforces on every protected request. + +See [`edge-totp/README.md`](edge-totp/README.md) for details. + ## License Apache-2.0. See [`LICENSE`](LICENSE). diff --git a/edge-totp/.gitignore b/edge-totp/.gitignore new file mode 100644 index 0000000..ec72101 --- /dev/null +++ b/edge-totp/.gitignore @@ -0,0 +1,150 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ + +# FastEdge build artifacts +*.wasm +/wasm/ +/otp-app/wasm/ +/otp-filter/target/ +/build/ diff --git a/edge-totp/AGENTS.md b/edge-totp/AGENTS.md new file mode 100644 index 0000000..57a5064 --- /dev/null +++ b/edge-totp/AGENTS.md @@ -0,0 +1,38 @@ +--- +doc_type: policy +audience: bot +lang: en +tags: ["ai-agents", "rules", "critical", "codex"] +last_modified: 2026-06-15T00:00:00Z +copyright: "© 2026 gcore.com" +--- + +# RULES FOR AI AGENTS + +TL;DR: Keep command output short. Do not take actions unless asked. +Do not waste tokens on experiments. Do only what is asked. + +# COMMUNICATION STYLE + +- Use English by default; if the user writes in another language, use that language +- Use an informal tone, avoid formal business language +- Question ideas and suggest alternatives — do not just agree with everything +- Think for yourself instead of agreeing to be polite + +# INVARIANTS + +- NEVER do anything beyond the assigned task +- NEVER change code that was not asked to change +- NEVER "improve" or "optimize" without a clear request +- NEVER use scripts for mass code replacements +- NEVER make architecture decisions on your own +- ALWAYS keep command output short — every extra line = wasted tokens +- ALWAYS think before acting — do not repeat checks, remember context +- ALWAYS ask an expert when the solution is not clear +- ALWAYS tell apart an observation from an action request: + observation ("works oddly") → discuss, DO NOT fix + request ("fix this") → act + +# PROJECT CONTEXT + +see CLAUDE.md diff --git a/edge-totp/CLAUDE.md b/edge-totp/CLAUDE.md new file mode 100644 index 0000000..4cb6eb4 --- /dev/null +++ b/edge-totp/CLAUDE.md @@ -0,0 +1,110 @@ +# TOTP MFA for FastEdge — Edge Two-Factor Login Enhancer + +## Governance (REQUIRED) + +Read `AGENTS.md` for company-wide agent rules. These are mandatory and override any conflicting behavior. Key rules: never go beyond the assigned task, never change code that was not asked to change, never "improve" or "optimize" without a clear request, always distinguish observations from action requests. + +--- + +## Project Goal + +A **sellable edge MFA bolt-on** for Gcore FastEdge — adds **TOTP (RFC 6238) two-factor authentication** in front of a customer's existing login, so they get a second factor *without building TOTP themselves*. The customer keeps their own login; our edge app handles the OTP challenge, verification, replay/rate-limiting, and hands back a signed proof their origin trusts. + +The governing principle is **"issue centrally, verify everywhere"**: the durable seed lives in per-customer KV; the edge only ever *verifies* and issues short-lived signed assertions. + +"Easy to deploy & configure" is the product promise: a FastEdge **HTTP app** the customer routes `/auth/totp/*` to via a CDN path rule, configured entirely through env vars + secrets. + +> **Read `context/INDEX.md` first**, then `context/architecture/overview.md` and `context/design/decisions.md`. For how a customer wires TOTP into their login, see `context/integration.md`. + +--- + +## Status — BOTH COMPONENTS BUILT + +- **`otp-app` (TypeScript HTTP app)** — implemented and deployed. All routes live: + challenge, verify, enroll, activate (self-service), logout, JWKS, health, KV seed. +- **`otp-filter` (Rust proxy-wasm)** — implemented: verifies `mfa_session`, + default-deny, fail-closed, traversal-safe bypass. +- Design rationale is in `context/design/decisions.md`. Deployed app IDs/URLs are + operational state and are kept out of this repo. + +--- + +## Architecture Overview + +A single FastEdge **HTTP app (TypeScript / Hono)** plus a **Rust proxy-wasm enforcement filter**, attached as a **CDN origin** on `/auth/totp/*` of the customer's existing CDN resource. Storage model is **KV-only**. Repo layout: + +``` +otp-app/ ← TypeScript HTTP app + src/ + tests/ + package.json / tsconfig.json +otp-filter/ ← Rust proxy-wasm enforcement filter + src/lib.rs + Cargo.toml +package.json ← workspace root; Rust build/test scripts here +pnpm-workspace.yaml ← lists otp-app +``` + +``` +Browser ── password login ──▶ Customer origin + │ password OK; user has TOTP enrolled + │ signs handoff ticket {userId,next,exp} (HANDOFF_KEY) + ▼ 303 redirect +Browser ──▶ totp-app GET /auth/totp/challenge?t= (hosted 6-digit UI) +Browser ──▶ totp-app POST /auth/totp/verify {t, code} + │ fetch seed for userId (KV read) + │ verify TOTP (crypto.subtle HMAC) + replay/rate-limit (Cache) + │ set mfa_session cookie (HS256 MFA_SESSION_KEY, 8h) + │ [Profile B] also mint one-time ES256 proof for origin (JWKS) + ▼ 303 redirect to next (same-host; no URL token) +Browser ──▶ Customer origin (A default: Rust filter checks mfa_session, default-deny; + B opt-in: origin verifies ES256 proof via JWKS, mints own session) +``` + +The seed is fetched **at verify time** (not held between steps) because `Cache` is POP-local and the origin-initiated start and browser-initiated verify usually hit different PoPs. See `context/architecture/flow.md` for the full flow and why. + +--- + +## Discovery Guide + +**Read when working on:** + +| Task | Read | +| --- | --- | +| **Orientation / what every file is (START HERE)** | `context/INDEX.md` | +| **What this is / product overview** | `context/architecture/overview.md` | +| **How it's built (design)** | `context/design/decisions.md` | +| The end-to-end challenge/verify flow + PoP reasoning | `context/architecture/flow.md` | +| Where secrets live, config/env/secrets, KV-mode + Gcore KV write API | `context/architecture/storage-and-secrets.md` | +| FastEdge JS runtime facts that constrain TOTP (crypto, KV, Cache) | `context/design/runtime-constraints.md` | +| How a customer wires TOTP into their login | `context/integration.md` | +| Build, test, and deploy | `README.md` | + +--- + +## Key Constraints (TOTP-relevant — full detail in `context/design/runtime-constraints.md`) + +- **TOTP = HMAC over a time-step.** `crypto.subtle` supports HMAC sign/verify (SHA-1/256/…) + raw key import — sufficient for RFC 6238. `Date.now()` is available in the app runtime. +- **No `crypto.subtle.encrypt`/`decrypt`/`generateKey`.** So a seed cannot be sealed into a browser-visible token; it must travel server-to-server only. +- **KV is read-only from the app** (`fastedge::kv`). Enrollment writes the seed through the **Gcore KV REST API** with a `GCORE_API_TOKEN` — `PUT /fastedge/v1/kv/{store_id}/data` (KV is the only seed store). +- **`Cache` is POP-local** and transient — fine for replay/rate-limit (defense-in-depth), not for cross-PoP seed handoff. +- **No shared state / no WebSocket** — request/response only; sessions are self-contained signed tokens. +- **HTTP App language**: TypeScript (Hono via `addEventListener("fetch", e => e.respondWith(app.fetch(e.request)))` — **not** `app.fire()`); StarlingMonkey runtime, no Node.js APIs. + +--- + +## FastEdge Platform Quick Reference + +- JS SDK: `@gcoredev/fastedge-sdk-js`; framework Hono wired via `addEventListener("fetch", e => e.respondWith(app.fetch(e.request)))` (**not** `app.fire()`, not `export default`) +- Secrets: `getSecret("KEY")` from `fastedge::secret` (request-time only) +- Env: `getEnv("KEY")` from `fastedge::env` (request-time only) +- KV (read): `KvStore.open("name").get(key)` from `fastedge::kv` → `ArrayBuffer | null` +- Cache: `Cache.incr/get/set` from `fastedge::cache` (POP-local, atomic counters, TTL) +- Outbound fetch: standard `fetch()` available (limited count per invocation) +- Deploy: HTTP app attached as CDN origin via a path rule (`/auth/totp/*`). +- **Build/scaffold/test/deploy via the `gcore-fastedge` plugin SKILLS** — they are the intelligence layer; the MCP server (Docker) is just their executor. Do **not** call MCP build tools or `./node_modules/.bin/fastedge-build` directly unless a skill reports its executor is down. + - `/gcore-fastedge:scaffold` — create project files from blueprints (do **not** hand-author `package.json`/`tsconfig`/`Cargo.toml`). + - `/gcore-fastedge:test` — TDD loop with `@gcoredev/fastedge-test`. + - `/gcore-fastedge:deploy` — build + pre-deploy test gate + deploy. + - `/gcore-fastedge:manage` / `:live-test` — env/secret sync + scenario tests against the deployed app. + - `/gcore-fastedge:fastedge-docs` — authoritative SDK/runtime reference (often answers inline without a round-trip). diff --git a/edge-totp/LICENSE b/edge-totp/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/edge-totp/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/edge-totp/README.md b/edge-totp/README.md new file mode 100644 index 0000000..efc0abb --- /dev/null +++ b/edge-totp/README.md @@ -0,0 +1,78 @@ +# totp-app — Edge TOTP MFA for FastEdge + +Adds a **TOTP (RFC 6238) two-factor step** in front of a website's existing login, +deployed at the edge on Gcore FastEdge. The site keeps its own password login; +this app hosts the 6-digit challenge, verifies the code (with replay and +brute-force protection), and hands back a signed assertion the origin trusts — so a +customer gets a second factor without building TOTP themselves. + +## Components + +| Component | Language | Role | +| --- | --- | --- | +| `otp-app/` | TypeScript + Hono (WASM) | HTTP app: challenge, verify, enroll, self-service activate, logout, JWKS, health. Signs the `mfa_session` cookie (HS256) and the optional ES256 proof. | +| `otp-filter/` | Rust (proxy-wasm) | CDN enforcement filter: verifies `mfa_session` on protected paths, **default-deny** and **fail-closed**. | + +**Two enforcement profiles:** **A** (default) — the filter enforces, zero origin +code; **B** (opt-in) — the origin verifies a one-time ES256 proof via JWKS and mints +its own session. + +## Build & test + +From the repo root: + +```bash +pnpm install +pnpm build # build both wasm binaries into ./wasm +pnpm test # run TS unit tests + Rust filter tests +``` + +Per-component scripts: `pnpm build:app` / `pnpm test:app` (otp-app), +`pnpm build:filter` / `pnpm test:filter` (otp-filter). + +## Configure + +Copy `otp-app/.env.example` and `otp-filter/.env.example` to `.env` and fill in real +values (`.env` is git-ignored). The full env/secret reference is in +[`context/architecture/storage-and-secrets.md`](context/architecture/storage-and-secrets.md). +For Profile B, generate the ES256 keypair with `node otp-app/scripts/gen-ec-keypair.mjs`. + +## Deploy + +Upload the compiled binaries to the Gcore portal under **FastEdge → Templates**: + +- `wasm/totp-app.wasm` — as an **HTTP App** template +- `wasm/totp-filter.wasm` — as a **Proxy-WASM** template + +Set the environment variables and secrets from your `.env` files on each template. + +**CDN wiring:** attach `otp-app` as a CDN origin on the `{AUTH_PREFIX}/*` path rule +of the customer's CDN resource; attach `otp-filter` as the CDN proxy app in front +of the protected paths (bypassing `{AUTH_PREFIX}` + `/health`). The app and the +origin share the CDN host so `mfa_session` is first-party host-only. + +## ⚠️ Security — read before deploying + +This is an authentication gate; misconfiguration can make it bypassable. At minimum: + +- **Lock the origin to edge-only traffic.** Any edge gate is meaningless if the + origin is directly reachable — an attacker just skips the CDN. Restrict the origin + to Gcore CDN ingress (IP allowlist / origin auth / tunnel). +- **Set `MFA_AUDIENCE`** on both apps when the filter is deployed. The filter + **fail-closes** (refuses every session) if it is unset. +- **TOTP seeds are stored plaintext-at-rest in KV.** Use a single-tenant, + per-customer isolated KV store; scope `GCORE_API_TOKEN` to that one store and treat + it as equivalent to every seed it can reach. +- **The edge `mfa_session` is short-lived (8h, non-sliding) and not cross-PoP + revocable.** Understand the accepted residual risks before relying on it. +- **CDN logs include user identity.** The filter logs the session subject (`sub`) + and request path on each authorized request. If `sub` contains PII, review your + CDN log retention policy. + +Full trust model, protections, and the residual risks knowingly accepted are in +[`context/security/threat-model.md`](context/security/threat-model.md), and the +customer-side wiring is in [`context/integration.md`](context/integration.md). + +## Documentation + +Start at [`context/INDEX.md`](context/INDEX.md). diff --git a/edge-totp/context/INDEX.md b/edge-totp/context/INDEX.md new file mode 100644 index 0000000..c8115a1 --- /dev/null +++ b/edge-totp/context/INDEX.md @@ -0,0 +1,58 @@ +# totp-app — Context Index + +> **How to use this folder:** Read *this index*, then open **only** the file(s) +> relevant to your task. Each file is one focused concept. You do **not** need to +> read the whole tree to help. Treat the code as the source of truth — if a doc and +> the code disagree, the code wins (and fix the doc). + +## What this app is (one paragraph) + +`totp-app` is a **FastEdge edge MFA bolt-on that adds TOTP (RFC 6238) two-factor +auth in front of a customer's existing login.** The customer keeps their own +password login; the edge hosts the 6-digit challenge, verifies the code, guards +against replay/brute-force, and produces a signed assertion the origin trusts. It +ships as two deployables: **`otp-app`** (TypeScript HTTP app — challenge, verify, +enroll, activate, logout, JWKS) and **`otp-filter`** (Rust proxy-wasm enforcement +filter that gates protected paths on the `mfa_session` cookie). Two enforcement +profiles: **A** (default — the filter enforces, zero origin code) and **B** (opt-in +— the origin verifies an ES256 proof via JWKS and mints its own session). + +## Map + +### architecture/ — how it fits together +- [overview.md](architecture/overview.md) — what the product is and the "issue + centrally, verify everywhere" model. **Start here.** +- [flow.md](architecture/flow.md) — the end-to-end enroll / challenge / verify flow, + the handoff ticket, the KV seed read, the two enforcement profiles, and the **PoP + reasoning** behind fetching the seed at verify time. +- [storage-and-secrets.md](architecture/storage-and-secrets.md) — where the seed + lives (**KV-only**, per-customer isolated store), the full config/env/secrets list, + and the Gcore KV write API. + +### design/ — how it's built +- [decisions.md](design/decisions.md) — the design: components, KV-only storage, + trust handoff, TOTP, the two profiles, token algorithms/TTLs, QR. +- [runtime-constraints.md](design/runtime-constraints.md) — the FastEdge JS runtime + facts that constrain the implementation (crypto.subtle matrix, read-only KV, + POP-local Cache, no encrypt/decrypt). + +### security/ — threat model +- [threat-model.md](security/threat-model.md) — trust model, the protections in the + code today, and the **residual risks knowingly accepted** (cross-PoP single-use / + brute force, KV revocation lag, plaintext seeds at rest). Read before touching the + verify or enforcement path, and before deploying. + +### integration.md — customer wiring +- [integration.md](integration.md) — how a customer wires TOTP into their existing + login: the three changes they make, the shared keys, and what the origin still + owns. + +## Quick task → file routing +- *"What is this?"* → architecture/overview.md +- *"How does the whole flow work / why fetch the seed at verify time?"* → architecture/flow.md +- *"Where do seeds live? what config exists?"* → architecture/storage-and-secrets.md +- *"How is it built?"* → design/decisions.md +- *"Can the runtime even do TOTP?"* → design/runtime-constraints.md +- *"What does it defend against / what risks are accepted?"* → security/threat-model.md +- *"How does a customer integrate it?"* → integration.md +- *"How do I build / deploy it?"* → README.md diff --git a/edge-totp/context/architecture/flow.md b/edge-totp/context/architecture/flow.md new file mode 100644 index 0000000..84ff433 --- /dev/null +++ b/edge-totp/context/architecture/flow.md @@ -0,0 +1,93 @@ +# Flow — Enroll, Challenge, Verify + +## Actors & shared keys + +- **Browser** — the end user. +- **Origin** — the customer's existing site. +- **Edge** — `totp-app` (the HTTP app) plus `otp-filter` (the Rust enforcement filter). + +Shared secrets (set as FastEdge secrets on the edge apps; matching values held by +the origin where noted): + +- `HANDOFF_KEY` — HMAC key the **origin** uses to sign the short-lived handoff + ticket and the **HTTP app** verifies. Proves "this user just passed the password + step." The only key shared with the origin in Profile A. +- `MFA_SESSION_KEY` — HS256 key the **HTTP app** signs `mfa_session` with and the + **Rust filter** verifies. Edge-internal; never shared with the origin. +- `MFA_PROOF_SIGNING_KEY` (**Profile B only**) — **ES256 private key** the HTTP app + signs the one-time proof with; the **origin** verifies it via the served **JWKS** + (public key only — it cannot forge). + +## Enrollment (one-time, per user) + +Two paths write a seed to KV: + +- **Admin-provisioned** — `POST {AUTH_PREFIX}/enroll`, server-to-server, gated by + `ENROLL_API_KEY` (constant-time compared). Generates a random base32 secret + (`crypto.getRandomValues`), writes it to KV via the Gcore KV REST API, and returns + `{ userId, otpauthUri, svgQr }`. +- **Self-service** — `GET {AUTH_PREFIX}/activate?t=` shows a QR + confirmation + form; the pending seed rides in a signed, short-lived `totp_enroll` cookie and is + **only written to KV after the user confirms a code** (`POST {AUTH_PREFIX}/activate`). + The challenge page redirects an unenrolled user here automatically. + +QR is rendered server-side to an inline `` via `uqr`. The `otpauth://` URI +embeds the seed, so it is rendered **locally only — never sent to an external QR +service**. + +## Challenge + verify (every login) + +1. Browser submits username+password to **Origin**. Origin verifies the password + (its existing logic). If the user has TOTP enrolled, it starts the challenge. +2. Origin signs a **handoff ticket** = HMAC(`HANDOFF_KEY`) over + `{ sub: userId, next: , iat, exp: now+TICKET_TTL }`, sets a short-lived + `pre_mfa` marker bound to `sub`, and **303-redirects** the browser to + `{AUTH_PREFIX}/challenge?t=`. The ticket carries no seed. +3. **HTTP app** `GET {AUTH_PREFIX}/challenge?t=`: verifies the ticket + signature, `exp` (required), and absolute age, then renders the 6-digit entry page + (form posts `t` + `code` to `/verify`). +4. Browser `POST {AUTH_PREFIX}/verify { t, code }`. The app: + a. Verifies the ticket → `userId`, `next`. + b. **Brute-force guard:** `Cache.incr("fail:"+userId)`; blocks over `MAX_ATTEMPTS` + within a TTL window. The counter is cleared on a successful verify. + c. **Fetches the seed** for `userId` from KV (`KvStore.open(KV_STORE_NAME) + .get(KV_KEY_PREFIX+userId)`). See "Why fetch at verify time" below. + d. **Verifies the code:** base32-decode seed → HMAC over the time-step counter + (`floor(Date.now()/1000 / TOTP_PERIOD)`), checking `±TOTP_DRIFT` steps, + constant-time compare. + e. **Replay guard:** atomically marks the consumed time-step in `Cache` + (`used::`, TTL ≈ the validity window) so a code can't be reused. + POP-local — defence-in-depth, not a global guarantee. + f. On success, mints the edge **`mfa_session`** = HS256(`MFA_SESSION_KEY`) + `{ sub, amr:["otp"], iat, exp: now+MFA_SESSION_TTL, iss?, aud? }`, sets it as a + host-only HttpOnly/Secure/SameSite=Lax cookie, and **303-redirects** to `next` + (validated relative/same-host). **Profile B** additionally mints a one-time + **ES256** proof `{ sub, amr:["otp"], iat, exp: now+PROOF_TTL, iss?, aud?, jti }` + and delivers it as a short-lived cookie (never in a URL). +5. Enforcement depends on the profile: + - **Profile A (default):** the **Rust filter** checks `mfa_session` on every request + (default-deny, fail-closed) and lets the user through. The origin needs no MFA + crypto — `HANDOFF_KEY` is the only key it holds. + - **Profile B (opt-in):** the origin verifies the one-time ES256 proof via the + served **JWKS** (public key only), confirms `sub` matches its `pre_mfa` user, and + mints its **own revocable session** with whatever lifetime it chooses. + +## Why fetch the seed at verify time (the PoP reasoning) — IMPORTANT + +The seed must reach the verifier and must **never be exposed to the browser** (no +`crypto.subtle.encrypt`, so it can't be sealed into a browser-visible token). + +Holding the seed in `Cache` between an origin-initiated start and a browser-initiated +verify does not work: `Cache` is **POP-local**, and the two calls usually hit +**different PoPs**. KV is the only cross-PoP store. So the app fetches the seed **at +verify time**, on the browser's PoP, via a KV read (globally replicated). The seed +travels edge↔KV only. `Cache` is used for replay/rate-limit as per-PoP +defence-in-depth. + +## Deployment & cookies + +Deploy the HTTP app as a CDN origin on `{AUTH_PREFIX}/*` of the customer's CDN +resource (path rule), so the challenge UI and the origin are the **same host** → the +`mfa_session` cookie is first-party host-only and `next` stays same-origin. Same-host +is required: the proof and session are consumed at the edge into a host-only cookie, +so there is no cross-host URL-token path. diff --git a/edge-totp/context/architecture/overview.md b/edge-totp/context/architecture/overview.md new file mode 100644 index 0000000..bd5ceb9 --- /dev/null +++ b/edge-totp/context/architecture/overview.md @@ -0,0 +1,35 @@ +# Overview — What This Is + +## The product + +`totp-app` is an **edge-deployed MFA enhancer**: a customer who already runs a +website with its own login puts this FastEdge app **in front of their login** to +add a **TOTP (RFC 6238) second factor** — without building TOTP themselves, and +without rewriting their backend. They get: a hosted 6-digit challenge page, +constant-time verification with clock-drift tolerance, replay and brute-force +protection, and a signed proof token their origin can verify. + +## Governing principle — "issue centrally, verify everywhere" + +The durable seed store is a **per-customer KV** (single-tenant, isolated); the edge +only ever *verifies* and issues a short-lived signed assertion — an `mfa_session` +cookie the filter checks, or an ES256 proof the origin verifies. The customer's site +stays the source of truth for *who the user is* (the password step); the edge is a +stateless verifier that produces a trustable "this user passed a second factor" +assertion. + +## Edge-integration model + +``` +Browser ─▶ Customer site ── password step + │ + └─ (user enrolled in TOTP) ─▶ [ Gcore FastEdge: totp-app ] + ├─ hosts the OTP challenge page + ├─ verifies the code (HMAC, drift window) + ├─ blocks replay / brute force (Cache) + └─ issues a signed proof the origin verifies +``` + +The TOTP **seed** lives in the per-customer KV store. The customer site keeps owning +identity; the edge proves only that a second factor succeeded. How a customer wires +this into their login is in [../integration.md](../integration.md). diff --git a/edge-totp/context/architecture/storage-and-secrets.md b/edge-totp/context/architecture/storage-and-secrets.md new file mode 100644 index 0000000..8b5faec --- /dev/null +++ b/edge-totp/context/architecture/storage-and-secrets.md @@ -0,0 +1,83 @@ +# Storage & Secrets — Seed location, KV mode, config + +## The only hard question: where is the TOTP seed at verify time? + +TOTP is symmetric — the verifier must hold the same per-user seed to recompute the +code. That per-user durable state is the whole storage question. + +**Storage model = KV only.** The seed lives in a **per-customer, single-tenant KV +store** (one deployment = one customer, so the store is isolated, not a multi-tenant +honeypot). Seeds are **plaintext-at-rest** in KV — scope `GCORE_API_TOKEN` to the +single store and treat KV read access as equivalent to the seeds themselves (see +`../security/threat-model.md` R4). + +### KV storage — the seed source + +- **Reads** (verify time): `KvStore.open(KV_STORE_NAME).get(KV_KEY_PREFIX+userId)` + via the `fastedge::kv` SDK. Globally replicated, so PoP-safe. +- **Writes** (enrollment): the SDK is **read-only**, so the edge writes via the + **Gcore KV REST API** using `GCORE_API_TOKEN`: + `PUT /fastedge/v1/kv/{KV_STORE_ID}/data`, sent as `application/json` with a body of + `[{ key: KV_KEY_PREFIX+userId, datatype: "kv", op: "add", payload: { value: , encoding: "plain" } }]` (see `otp-app/src/seed/kv.ts`). + +> Why KV and not "hand the seed off at start": the seed must reach the verifier at +> the **browser's** PoP, and `Cache` is POP-local. KV is the only globally-replicated +> store, and the seed must never reach the browser (no `crypto.subtle.encrypt`). See +> `architecture/flow.md` "Why fetch the seed at verify time." + +## Configuration + +### Environment variables (`getEnv`, non-secret) + +| Var | Default | Purpose | +| --- | --- | --- | +| `AUTH_PREFIX` | `/auth/totp` | Mount path for this app. Used by both the HTTP app (Hono base path, JWKS URL) and the Rust filter (bypass prefix). Set to match the CDN path rule. | +| `TOTP_ISSUER` | — | Issuer label in the `otpauth://` URI shown in the authenticator app | +| `TOTP_DIGITS` | `6` | Code length | +| `TOTP_PERIOD` | `30` | Time-step seconds | +| `TOTP_ALGORITHM` | `SHA1` | HMAC hash (SHA1 is the RFC 6238 default; authenticator apps assume it) | +| `TOTP_DRIFT` | `1` | Allowed ± time-steps for clock skew | +| `TICKET_TTL` | `90` | Handoff-ticket lifetime (s) — short, single-use. Set by the origin when it signs the ticket; the app independently requires `exp` and caps absolute age. | +| `PROOF_TTL` | `90` | Profile-B one-time proof lifetime (s); single-use | +| `MFA_SESSION_TTL` | `28800` | `mfa_session` lifetime (s) — **8h, absolute** (non-sliding) re-MFA interval | +| `ENROLL_TTL` | `600` | `totp_enroll` cookie lifetime (s) — window for the user to scan QR + confirm code | +| `MAX_ATTEMPTS` | `5` | Failed codes before lockout (per Cache window); cleared on success | +| `ALLOW_SELF_ENROLLMENT` | `true` | Whether unenrolled users may self-enroll on first login via `{AUTH_PREFIX}/activate`. Set `false` for admin-provisioned deployments — `/activate` returns 403 and `/challenge` refuses unenrolled users. See `../security/threat-model.md` R5. | +| `MFA_SESSION_COOKIE` | `mfa_session` | Edge session cookie the Rust filter checks (Profile A); host-only, no `Domain` | +| `MFA_PROOF_COOKIE` | `mfa_proof` | Name of the one-time ES256 proof cookie (Profile B) | +| `MFA_AUDIENCE` | — | `aud` claim embedded in `mfa_session` and the proof. **Required when the filter is deployed** (the filter fail-closes without it). Should be the CDN hostname (e.g. `https://app.example.com`). | +| `MFA_ISSUER` | — | `iss` claim embedded in `mfa_session` and the proof. Validated only when set on both sides. | +| `MFA_PROOF_PUBLIC_JWK` | — | Pre-computed public JWK JSON for the Profile-B JWKS endpoint. Generated offline (`exportKey` is unavailable in the runtime, so the public JWK is stored here rather than derived at runtime). | +| `KV_STORE_NAME` | — | KV store name (seed store) — used by the `fastedge::kv` SDK for reads | +| `KV_STORE_ID` | — | KV store numeric ID — used for writes via the Gcore KV REST API | +| `KV_KEY_PREFIX` | `totp:` | Prefix prepended to userId for KV keys. Default is `DEFAULT_KEY_PREFIX = "totp:"` (single source of truth in `seed/kv.ts`). Override to share one KV store across multiple apps without key collisions. | +| `GCORE_API_URL` | `https://api.gcore.com` | Gcore API base for KV writes | +| `TOTP_BRAND_NAME` | — | Appended to page `` and used as logo `alt` text | +| `TOTP_BRAND_LOGO_URL` | — | URL of a logo image shown above the form (max 48 × 180 px) | +| `TOTP_BRAND_FAVICON_URL` | — | URL of a favicon injected as `` | +| `TOTP_BRAND_BUTTON_COLOR` | `#0066cc` | Button background + input focus-ring colour | +| `TOTP_BRAND_BUTTON_HOVER_COLOR` | — | Explicit hover background. If unset, `filter: brightness(0.88)` is applied instead. | + +> The JWKS path is derived at runtime from `AUTH_PREFIX` +> (`{AUTH_PREFIX}/.well-known/jwks.json`) — there is no separate path env var. + +### Secrets (`getSecret`) + +| Secret | Purpose | +| --- | --- | +| `HANDOFF_KEY` | HS256 key for the origin→edge handoff ticket (shared with origin) | +| `MFA_SESSION_KEY` | HS256 key for the `mfa_session` cookie (edge-internal: HTTP app signs ↔ Rust filter verifies) | +| `MFA_PROOF_SIGNING_KEY` | **ES256 private key (PKCS8)** — edge signs the Profile-B proof; origin verifies via JWKS. **Profile B only.** | +| `ENROLL_API_KEY` | Gates `POST /auth/totp/enroll` | +| `GCORE_API_TOKEN` | Token for KV seed writes via the Gcore API (scope to the single store) | + +Use `dotenv` sync (the `manage`/`live-test` skills sync `fixtures/.env*` to the +deployed app) — see the FastEdge dotenv docs. + +> **Profile-B keypair:** generate the ES256 keypair with +> `node otp-app/scripts/gen-ec-keypair.mjs` (add `--dotenv` for `.env`-ready +> lines). It prints `MFA_PROOF_SIGNING_KEY` (the PKCS#8 private key — set as a +> secret) and `MFA_PROOF_PUBLIC_JWK` (the public JWK — set as an env var, served +> at the JWKS endpoint). The public JWK is stored rather than derived at runtime +> because `exportKey` is unavailable in the FastEdge runtime. diff --git a/edge-totp/context/design/decisions.md b/edge-totp/context/design/decisions.md new file mode 100644 index 0000000..1cdd029 --- /dev/null +++ b/edge-totp/context/design/decisions.md @@ -0,0 +1,102 @@ +# Design Rationale + +Why `totp-app` is built the way it is. This records the *current* design and the +reasoning behind it — not a change history. + +## Components + +- **`otp-app` — TypeScript + Hono HTTP app.** TOTP needs `crypto.subtle` HMAC, + routing, and a hosted challenge/enrollment UI; the FastEdge JS HTTP-app runtime + provides all three. A proxy-wasm filter alone (Rust/AS) cannot host the UI. +- **`otp-filter` — Rust proxy-wasm enforcement filter.** Runs in the CDN proxy in + front of the customer's protected origin, verifies the `mfa_session` cookie on + every request, and is **default-deny / fail-closed**. Enforcement lives in our + code, not the customer's origin. + +Both are deployed as FastEdge apps on the customer's CDN resource: the HTTP app on +the `AUTH_PREFIX` path rule, the filter in front of the protected paths. + +## Storage — KV only, single-tenant + +The TOTP seed lives in a **per-customer Gcore KV store**. One deployment serves one +customer, so the store holds only that customer's seeds in isolation — not a +multi-tenant honeypot. The app reads KV directly (`fastedge::kv`) and writes through +the Gcore KV REST API (`GCORE_API_TOKEN`), because the KV SDK is read-only. + +Scope `GCORE_API_TOKEN` to the single store so a leak cannot reach other data. The +seed is **never exposed to the browser** — the runtime has no `crypto.subtle.encrypt`, +so a seed can't be sealed into a browser-visible token; it travels edge↔KV only and +is fetched at verify time (see `../architecture/flow.md` for the PoP reasoning). + +## Trust handoff — signed ticket, no seed + +The origin stays the source of truth for *who the user is*. After its password +check it signs a short-lived **handoff ticket** (HMAC over `HANDOFF_KEY`) carrying +`{ sub, next, exp }` and redirects the browser to the challenge page. The ticket +carries no seed and the origin can mint it itself — no "start" call to the edge. + +## TOTP + +RFC 6238 via `crypto.subtle` HMAC (SHA-1 default, configurable), base32 seed, RFC +4226 dynamic truncation, `±TOTP_DRIFT` step window, constant-time compare. SHA-1 is +the authenticator-app default. See `runtime-constraints.md`. + +## Two enforcement profiles + +- **Profile A (default, edge-enforced).** The Rust filter verifies the `mfa_session` + cookie and gates protected paths. The origin runs **no MFA crypto** — `HANDOFF_KEY` + is the only key it holds ("zero origin code"). +- **Profile B (opt-in, origin-verified).** The edge mints a one-time **ES256** proof; + the origin verifies it via the served **JWKS** endpoint (public key only — it + cannot forge), then mints its own revocable, longer-lived session. + +The trust anchor sets the session model: the edge `mfa_session` is stateless and +cross-PoP-unrevocable, so it stays short; a durable, revocable session belongs at +the origin, which has a database. Profile A is the easy-deploy headline; Profile B +is for customers wanting longer or origin-controlled sessions. + +## Tokens, algorithms, TTLs + +- **`mfa_session`** (edge HTTP app → filter): **HS256** over `MFA_SESSION_KEY`, + `MFA_SESSION_TTL` = 8h absolute (non-sliding), host-only HttpOnly/Secure/SameSite=Lax + cookie. Never crosses to the origin. 8h ≈ a workday; kept short because in Profile A + it is the sole, unrevocable boundary. +- **Profile-B proof**: **ES256** over `MFA_PROOF_SIGNING_KEY`, `PROOF_TTL` ≈ 90s, + single-use (`jti` + POP-local replay guard). Asymmetric so the origin holds only a + public key. Delivered as a short-lived cookie, never in a URL. +- **Handoff ticket**: HS256 over `HANDOFF_KEY`. The edge requires `exp` and caps the + absolute age (defence-in-depth). +- All verification pins its algorithm and rejects `none`/alg-switch — HS256 in the + HTTP app and the Rust filter, ES256 for the proof. +- `iss`/`aud` are bound on `mfa_session` and the proof. The filter **fail-closes** if + `MFA_AUDIENCE` is unset (it cannot otherwise know which tokens are its own); `iss` + is checked only when set on both sides. + +## QR rendering — server-side SVG, local only + +Enrollment renders the `otpauth://` URI to an inline `` with `uqr` (pure-JS / +SVG-string — no canvas or Node built-ins, which the runtime lacks). The URI embeds +the seed, so it is rendered **locally only and never sent to an external QR service**. + +## Configurable mount path + +The mount path is `AUTH_PREFIX` (default `/auth/totp`), read by both components. The +filter bypasses `{AUTH_PREFIX}` and `{AUTH_PREFIX}/…` plus `/health`; everything else +requires a valid session. Unauthenticated requests are redirected to `MFA_LOGIN_URL` +(a URL on the customer origin that starts the MFA flow), or get a 401 if it is unset +(useful for APIs). A customer who already owns `/auth/` can mount the app elsewhere +via their CDN path rule and set `AUTH_PREFIX` to match. + +## Enrollment & recovery + +Two enrollment paths write the seed to KV: **admin-provisioned** +(`POST {AUTH_PREFIX}/enroll`, gated by `ENROLL_API_KEY`) and **self-service first-login** +(`{AUTH_PREFIX}/activate`, on by default, gated by the handoff ticket). Self-service can +be turned off with `ALLOW_SELF_ENROLLMENT=false` for admin-provisioned deployments. + +Identity proofing — confirming a human really is the account owner — is the **origin's** +responsibility, not the edge's (the edge holds no durable identity data). So account +recovery is delegated to the origin: a lost authenticator is re-provisioned by calling +`POST {AUTH_PREFIX}/enroll` with `force:true` behind the origin's own identity-proofing +flow. Backup codes are not issued, and there is no standalone enrollment UI beyond the +hosted `/activate` page. See `../security/threat-model.md` R5. diff --git a/edge-totp/context/design/runtime-constraints.md b/edge-totp/context/design/runtime-constraints.md new file mode 100644 index 0000000..a3959ee --- /dev/null +++ b/edge-totp/context/design/runtime-constraints.md @@ -0,0 +1,60 @@ +# Runtime Constraints — What the FastEdge JS runtime allows for TOTP + +The HTTP-app JS runtime is **StarlingMonkey** (SpiderMonkey, WinterCG-style). NOT +Node.js — no `node:crypto`, `fs`, `process`, `require`; no WebSocket; no DOM. +Authoritative reference: the `gcore-fastedge:fastedge-docs` skill (`js-runtime` / +`sdk-reference-js`). + +## Crypto — sufficient for TOTP + +`crypto.subtle` capability matrix (the parts that matter here): + +| Op | Supported | +| --- | --- | +| `digest` | SHA-1, SHA-256, SHA-384, SHA-512, MD5 | +| `sign` / `verify` | **HMAC**, RSASSA-PKCS1-v1_5, ECDSA | +| `importKey` | **raw (HMAC)**, JWK, SPKI, PKCS#8 | +| `getRandomValues`, `randomUUID` | ✓ | +| `encrypt` / `decrypt` / `generateKey` / `deriveKey` / `exportKey` | **NOT implemented** | + +Implications: +- **TOTP works**: import the base32-decoded seed as a raw HMAC key, `sign` the + 8-byte big-endian time-step counter, apply RFC 4226 dynamic truncation, mod + `10^digits`. Default hash SHA-1 (RFC 6238 / authenticator-app default). +- **The `mfa_session` and handoff ticket** are HS256 — `importKey(raw, HMAC SHA-256)` + + `sign`/`verify`. The Profile-B proof is ES256 (`importKey(pkcs8, ECDSA P-256)`). +- **No `encrypt`** ⇒ a seed cannot be sealed into a browser-visible token. This is + the root reason the seed travels **server-to-server only** and is fetched at + verify time (see `architecture/flow.md`). +- `Date.now()` **is** available in the app runtime (used for the time-step and JWT + `exp`). + +There is **no base32 in the standard library** — implement RFC 4648 base32 +decode/encode yourself (small helper). `atob`/`btoa`, `TextEncoder/Decoder` are +available. + +## Storage primitives + +- **`fastedge::kv`** (`KvStore.open(name).get(key) → ArrayBuffer | null`) is + **read-only from the app**. Writes go through the Gcore KV REST API + (`GCORE_API_TOKEN`). Globally replicated, eventual consistency. +- **`fastedge::cache`** (`Cache.incr/get/set/expire`, atomic counters, TTL) is + **POP-local** and transient. Good for replay marks + brute-force counters; NOT a + cross-PoP store and NOT durable. +- **`fastedge::secret`** (`getSecret`) and **`fastedge::env`** (`getEnv`) are + **request-time only** — never call them at module top level (causes a 531). + Always handle `null`. + +## Platform limits (Basic / Pro) + +- Execution time 50 ms / 200 ms; memory 128 MB / 256 MB. +- Outbound `fetch` 5 / 20 per invocation — KV seed reads/writes count against this; + keep verify to a single outbound call. +- Request/response body 1 MB / 5 MB. + +## Framework + +Hono wired via the **service-worker pattern** — +`addEventListener("fetch", (e) => e.respondWith(app.fetch(e.request)))` — **not** +`app.fire()`, not `export default`, not `Deno.serve`. Incoming `request.headers` is +read-only. diff --git a/edge-totp/context/integration.md b/edge-totp/context/integration.md new file mode 100644 index 0000000..83d7abd --- /dev/null +++ b/edge-totp/context/integration.md @@ -0,0 +1,89 @@ +# Integration — Wiring TOTP into a Customer Login + +This describes how a customer adds the TOTP second factor to a site that already +has its own username/password login. The origin keeps owning *who* the user is; +totp-app adds the second-factor step in front of it. + +## The three changes a customer makes + +Adding the TOTP second factor is **three small changes** on the origin side, all in +the auth layer — the rest of the site is untouched: + +1. **Split login into password-then-OTP.** After the password check succeeds, + instead of immediately minting the full origin session: + - sign a **handoff ticket** = HMAC(`HANDOFF_KEY`) over + `{ sub: userId, next, exp: now+TICKET_TTL }`; + - set a short-lived **`pre_mfa`** cookie/marker so the origin remembers the + password step passed (bind it to `sub`); + - 303-redirect to `{AUTH_PREFIX}/challenge?t=` (totp-app, same host + via the CDN path rule). + +2. **Finish login on return to `next` — pick a profile:** + - **Profile A (default, zero origin code):** the Rust filter enforces the edge + `mfa_session` (8h) on protected paths, so any request reaching the origin has + passed MFA. The origin just re-identifies its `pre_mfa` user and mints its own + session — **no proof verification, no crypto.** `HANDOFF_KEY` is the only key + it holds. + - **Profile B (opt-in, longer sessions):** the edge hands back a one-time **ES256** + proof; the origin verifies it via totp-app's **JWKS** endpoint + (`createRemoteJWKSet` — public key only, can't forge), checks `sub` matches + `pre_mfa`, and mints its **own revocable session** with whatever lifetime it + wants (safely longer than the 8h edge session). + The proof is **never in a URL** — it is delivered as a short-lived cookie. + +3. **Enroll users.** No origin endpoint needed: call + `POST {AUTH_PREFIX}/enroll` (gated by `ENROLL_API_KEY`), which writes the seed to + KV (via totp-app's `GCORE_API_TOKEN`); verify reads it from KV at challenge time. + Users can also self-enroll on first login via `{AUTH_PREFIX}/activate` (on by + default; set `ALLOW_SELF_ENROLLMENT=false` to require admin provisioning instead). + **Recovery:** re-provision a lost authenticator by calling `/enroll` with + `force:true` behind your own identity check. Self-service enrollment inherits the + password's trust level — see [security/threat-model.md](security/threat-model.md) R5 + before relying on it for sensitive accounts. + +Only the origin's auth module changes; the rest of the site stays as-is. + +## Shared configuration (origin ⇄ edge) + +| Key | Origin uses it to… | Edge uses it to… | +| --- | --- | --- | +| `HANDOFF_KEY` (HS256) | sign the handoff ticket | verify it on `/challenge` + `/verify` | +| **JWKS** (Profile B only) | verify the one-time ES256 proof via `createRemoteJWKSet` (**public key only**) | sign the proof (`MFA_PROOF_SIGNING_KEY`) + serve `{AUTH_PREFIX}/.well-known/jwks.json` | + +> **Profile A shares only `HANDOFF_KEY`** — the origin holds no verification key at +> all; the Rust filter enforces `mfa_session` (HS256 `MFA_SESSION_KEY`, edge-internal). +> Profile B adds the JWKS public-key fetch. No symmetric secret crosses to the origin +> on the proof path. + +Both apps live on the **same CDN host** (path rule `{AUTH_PREFIX}/*` → totp-app), so +the `mfa_session` cookie is first-party and host-only. Same-host is **required**: the +proof and session are consumed at the edge into a host-only cookie, so there is no +cross-host URL-token path. + +## What the edge asserts — and what the origin still owns (read before shipping) + +The edge proves **"a second factor succeeded"**, not **"this specific request is user X."** +Getting this boundary right is the difference between a real MFA gate and a bypassable one. + +- **Identity stays the origin's job.** In **Profile A** the Rust filter only verifies + that *a* valid `mfa_session` exists — it does not bind that session to the origin's + password identity, and it deliberately does **not** forward the user id to the origin. + That's correct here: the origin already authenticated the password and re-identifies + its `pre_mfa` user when minting its session. Do **not** add an unsigned + `x-mfa-user`-style header for the origin to trust — forwarded-identity headers are a + recurring source of auth-bypass CVEs (e.g. oauth2-proxy header smuggling). If you need + the *edge* to assert **which** user passed MFA, use **Profile B**: the ES256 proof + carries `sub`, the origin verifies it via JWKS and checks it matches `pre_mfa`. This is + the same signed-assertion pattern Cloudflare Access uses (`Cf-Access-Jwt-Assertion`), + and the reason the proof is signed rather than a bare header. + +- **Lock the origin to edge-only traffic (required).** Any edge gate — Profile A or B — + is only meaningful if the origin **cannot be reached except through the CDN**. If the + origin's IP is directly reachable, an attacker simply skips the edge and the + `mfa_session`/filter never runs. Customers must restrict the origin to Gcore CDN + ingress (IP allowlist / origin auth / tunnel). Profile B is more robust here because + the origin *independently verifies* the signed proof rather than trusting that the + request came through the gate — prefer it when the origin can't be fully locked down. + +The env/secret list to configure both apps is in +[architecture/storage-and-secrets.md](architecture/storage-and-secrets.md). diff --git a/edge-totp/context/security/threat-model.md b/edge-totp/context/security/threat-model.md new file mode 100644 index 0000000..53780be --- /dev/null +++ b/edge-totp/context/security/threat-model.md @@ -0,0 +1,103 @@ +# Threat Model & Residual Risks + +What `totp-app` defends against, how, and the risks it knowingly accepts because the +FastEdge platform can't do better. Read before touching the verify or enforcement +path. + +## Trust model + +- **Single-tenant**: one totp-app deployment = one customer. No multi-tenancy. +- Three actors: **Browser**, **Origin** (customer login — source of truth for *who* + the user is), **Edge** (this app + its Rust enforcement filter — verifies the + second factor). +- Realistic attacker for TOTP: **already holds the password** (phish / credential + stuffing) and is trying to defeat the 6-digit factor. So they *can* drive the + login flow and reach `/verify`. + +## Protections in place + +**Token & crypto integrity** +- Algorithms are **pinned**: `mfa_session` and the handoff ticket verify HS256 only; + the Profile-B proof is ES256. The Rust filter rejects any non-HS256 token before + touching the signature, so `none`/alg-switch is not reachable. +- Signature checks are constant-time (`verify_slice` in the filter, jose in the app). + TOTP code comparison and the enroll API-key comparison use a constant-time helper. +- The Profile-B proof is **ES256/JWKS**: the origin holds only the public key and + cannot forge proofs. Only `HANDOFF_KEY` is symmetric-shared with the origin (it is + the legitimate ticket minter). `mfa_session` (HS256) stays edge-internal. + +**Session / ticket handling** +- Handoff ticket **requires `exp`** and is rejected past an absolute max age; a ticket + carrying a `seed` claim is rejected (prevents enroll-cookie/handoff confusion). +- The enroll cookie is **purpose-bound** (`purpose: "totp-enroll"`), so it can't be + replayed through the handoff path even though it shares `HANDOFF_KEY`. +- The handoff ticket is **single-use**: on a successful `/verify` the ticket's + fingerprint is recorded in `Cache` and a re-presented ticket is refused + (`ticketFingerprint` + `Cache.exists`/`set` in `index.ts`). POP-local best-effort — + see R2. +- `iss`/`aud` are bound on `mfa_session` and the proof. The proof is single-use + (`jti` + replay guard), short-TTL, and delivered as a cookie — **never in a URL**. +- `mfa_session` is a host-only HttpOnly/Secure/SameSite=Lax cookie, 8h absolute. + +**Verify path** +- **Replay guard** marks the consumed time-step atomically in `Cache` (incr-based, no + TOCTOU window) so a code can't be reused within its validity window. +- **Brute-force guard** counts failures per user in `Cache`, locks over `MAX_ATTEMPTS`, + and clears on a successful verify. (POP-local — see R2.) +- The seed is fetched from KV at verify time and **never exposed to the browser** (the + runtime has no `crypto.subtle.encrypt`). + +**Enforcement filter (`otp-filter`)** +- **Default-deny**: bypasses only `{AUTH_PREFIX}` paths and `/health`; everything else + requires a valid `mfa_session`. Does not enumerate "protected" paths. +- **Fail-closed** on missing key / bad signature / expired token, and on an unset + `MFA_AUDIENCE` (it refuses every session rather than trust a token it can't scope). +- **Traversal-safe bypass**: refuses to bypass paths containing `..`, `//`, `\`, or + percent-encoded dots/slashes, so a path the origin would normalise (e.g. + `{AUTH_PREFIX}/../admin`) cannot slip past the gate. +- Honours `nbf` when present; the unauthenticated redirect target is a **relative** + path only (no attacker-controlled Host folded into `redirect=`). + +**Hosted UI** +- All reflected values (branding, error codes) are HTML-escaped; `Referrer-Policy: + no-referrer` and no third-party resources on the challenge/enroll pages limit + ticket leakage and clickjacking surface. + +## Residual risks we accept (platform-constrained) + +`Cache` is POP-local with no cross-PoP view; KV is read-only from the app and +eventually consistent with no atomic counters. These cannot be fully closed on +FastEdge: + +- **R1 — Cross-PoP single-use.** A captured proof or `mfa_session` can be replayed at + a *different* PoP within its TTL; there is no global denylist. **Bound:** short TTLs, + `aud`/`iss`/`jti` binding. +- **R2 — Cross-PoP brute force.** A distributed attacker who already holds the password + can spread code guesses across PoPs to evade the per-PoP `Cache` cap. **Bound:** + short single-use ticket + low per-ticket attempt cap + relogin-on-fail, delegating + the global limit to the **origin's login rate-limiting**. Optional 7–8 digit codes + multiply the keyspace 10–100×. +- **R3 — KV revocation lag.** A rotated/deprovisioned seed may still verify briefly at + some PoPs (eventual consistency). Relevant to offboarding; accepted. +- **R4 — TOTP seeds are plaintext-at-rest in KV.** The seed is written with + `encoding: "plain"` (`otp-app/src/seed/kv.ts`) and read back as-is. The runtime has + no `crypto.subtle.encrypt`/`decrypt`/`generateKey`, so the app cannot seal the seed + with a key it holds only at request time. **Anyone with KV read access — or the + `GCORE_API_TOKEN` — can dump every enrolled seed and mint valid codes for every + enrolled user.** **You must:** use a single-tenant, per-customer isolated KV store; + scope `GCORE_API_TOKEN` to that one store and treat it as equivalent to every seed it + can reach; restrict and rotate KV access accordingly. Protecting the values at rest + is a property of the KV store, not this app — enable any at-rest protection the store + offers. +- **R5 — Enrollment & recovery trust (delegated to the origin).** First enrollment and + account recovery are only as strong as the password until a second factor is bound + (trust-on-first-use). totp-app deliberately does **not** perform identity proofing — + that is the origin's job. Self-service `/activate` is **on by default** for easy + deployment and inherits the password's trust level: an attacker who already holds the + password for a not-yet-enrolled account can bind their own authenticator. **Bound:** + for sensitive accounts, enroll proactively via `POST {AUTH_PREFIX}/enroll` (so there is + no unenrolled-but-password-valid window), or set `ALLOW_SELF_ENROLLMENT=false` to + disable `/activate` entirely. **Recovery:** a lost authenticator is re-provisioned by + calling `POST {AUTH_PREFIX}/enroll` with `force:true` behind the origin's own + identity-proofing flow (helpdesk, email re-verification, etc.). No backup codes are + issued — recovery is the origin's responsibility. diff --git a/edge-totp/otp-app/.env.example b/edge-totp/otp-app/.env.example new file mode 100644 index 0000000..010fee9 --- /dev/null +++ b/edge-totp/otp-app/.env.example @@ -0,0 +1,202 @@ +# ============================================================================ +# otp-app — TOTP HTTP app configuration +# ============================================================================ +# Copy this file to .env and fill in real values. .env is git-ignored — never +# commit real secrets. +# +# Two prefixes map to two places in the Gcore portal: +# FASTEDGE_VAR_ENV_ -> Environment Variables (non-sensitive config) +# FASTEDGE_VAR_SECRET_ -> Secret Manager (sensitive values) +# +# ---------------------------------------------------------------------------- +# Keys shared with the CDN filter (otp-filter) — MUST match otp-filter's .env +# ---------------------------------------------------------------------------- +# MFA_SESSION_KEY the filter verifies mfa_session tokens with this key +# MFA_SESSION_COOKIE the cookie name the filter reads +# AUTH_PREFIX the filter bypasses requests to this path prefix +# MFA_AUDIENCE the filter rejects any token whose aud != this value +# MFA_ISSUER (optional) checked by the filter only when set on both +# +# Keys shared with the customer origin +# ---------------------------------------------------------------------------- +# HANDOFF_KEY the origin signs the handoff ticket; this app verifies it +# ============================================================================ + + +# --- TOTP session signing (edge-internal: otp-app <-> otp-filter) ---------- + +# REQUIRED. HS256 key used to sign the mfa_session JWT that the CDN filter +# verifies on every protected request. Use a long random string (32+ bytes). +# Must be set on BOTH otp-app (signs) and otp-filter (verifies). +FASTEDGE_VAR_SECRET_MFA_SESSION_KEY=change-me-to-a-long-random-string + + +# --- Handoff ticket (shared with customer origin) -------------------------- + +# REQUIRED. HS256 key for the short-lived handoff ticket the customer origin +# signs after a successful password check and this app verifies on /challenge +# and /verify. Must be set on BOTH the origin and this app. +FASTEDGE_VAR_SECRET_HANDOFF_KEY=change-me-to-a-long-random-string + + +# --- Token binding (audience / issuer) ------------------------------------- + +# OPTIONAL — but REQUIRED if otp-filter is deployed. The audience embedded in +# the mfa_session token. The filter refuses every session unless its MFA_AUDIENCE +# matches. Think of it as this deployment's identity token: +# - One deployment: give it a unique value (e.g. https://app.example.com). +# - Share MFA sessions across apps: use the SAME value on all of them. +# Must match MFA_AUDIENCE on otp-filter. Leave unset only when running without +# the filter (Profile B only, or testing). +# FASTEDGE_VAR_ENV_MFA_AUDIENCE=https://app.example.com + +# OPTIONAL. Issuer claim embedded in the mfa_session token. Validated by the +# filter only when MFA_ISSUER is set on BOTH sides. Defense-in-depth. +# FASTEDGE_VAR_ENV_MFA_ISSUER=https://auth.example.com + + +# --- Session cookie --------------------------------------------------------- + +# Cookie name that carries the mfa_session token. Must match MFA_SESSION_COOKIE +# on otp-filter. Change only if "mfa_session" collides with an existing cookie. +# Default: mfa_session +FASTEDGE_VAR_ENV_MFA_SESSION_COOKIE=mfa_session + +# How long the mfa_session cookie lives (seconds). Default: 28800 (8 hours). +# FASTEDGE_VAR_ENV_MFA_SESSION_TTL=28800 + + +# --- KV seed store (required for verify; required for enroll) --------------- + +# REQUIRED for /verify. The fastedge KV store name where TOTP seeds are stored. +# Create the store in the Gcore portal and populate it via POST /auth/totp/enroll. +FASTEDGE_VAR_ENV_KV_STORE_NAME=totp-seeds + +# REQUIRED for /enroll. The numeric KV store ID (from the Gcore portal). +# Used to write seeds via the Gcore KV REST API. +FASTEDGE_VAR_ENV_KV_STORE_ID=12345 + +# Prefix prepended to userId for KV keys. Default: totp:. Override only to share +# one KV store across multiple apps without key collisions. +# FASTEDGE_VAR_ENV_KV_KEY_PREFIX=totp: + + +# --- Enroll API key --------------------------------------------------------- + +# REQUIRED for POST /auth/totp/enroll. A bearer token to gate the enroll +# endpoint. Set this to a long random string and pass it as +# Authorization: Bearer when calling enroll from your backend. +FASTEDGE_VAR_SECRET_ENROLL_API_KEY=change-me-to-a-long-random-string + + +# --- Self-service enrollment ------------------------------------------------ + +# Whether an unenrolled user may self-enroll on first login via +# {AUTH_PREFIX}/activate. Default: true. Set to false for admin-provisioned +# deployments (enroll users with POST {AUTH_PREFIX}/enroll instead): /activate +# then returns 403 and /challenge tells unenrolled users to contact an admin. +# Self-service enrollment inherits the password's trust level — see the threat +# model (R5) before relying on it for sensitive accounts. +# FASTEDGE_VAR_ENV_ALLOW_SELF_ENROLLMENT=true + + +# --- Gcore API (for KV writes from /enroll) --------------------------------- + +# REQUIRED for /enroll. API token with KV write access. Obtained from the +# Gcore portal under API Tokens. Scoped to KV write only if possible. +FASTEDGE_VAR_SECRET_GCORE_API_TOKEN=your-gcore-api-token + +# Gcore API base URL. Default: https://api.gcore.com +# FASTEDGE_VAR_ENV_GCORE_API_URL=https://api.gcore.com + + +# --- TOTP parameters -------------------------------------------------------- + +# Issuer name shown in the user's authenticator app (Google Authenticator, +# Authy, etc.) next to the account. Default: TOTP +FASTEDGE_VAR_ENV_TOTP_ISSUER=MyApp + +# Number of digits in the OTP code. Default: 6. Most authenticators support 6. +# FASTEDGE_VAR_ENV_TOTP_DIGITS=6 + +# Time step in seconds. Default: 30. Must match the authenticator's setting. +# FASTEDGE_VAR_ENV_TOTP_PERIOD=30 + +# HMAC algorithm. Default: SHA1. RFC 6238 mandates SHA1 for standard TOTP; +# most authenticators only support SHA1. +# FASTEDGE_VAR_ENV_TOTP_ALGORITHM=SHA1 + +# Clock drift tolerance in ±steps. Default: 1 (accepts current ± one step). +# Increase only on known clock-skew deployments. +# FASTEDGE_VAR_ENV_TOTP_DRIFT=1 + + +# --- Handoff ticket TTL ----------------------------------------------------- + +# How long the handoff ticket (signed by the origin) is valid (seconds). +# Default: 90. Should be short — the user is mid-login flow. +# FASTEDGE_VAR_ENV_TICKET_TTL=90 + + +# --- Brute-force protection ------------------------------------------------- + +# Max failed /verify attempts per user per 5-minute window before locking. +# Default: 5. POP-local — the same user hitting a different PoP gets a fresh +# counter. Defense-in-depth only; not a substitute for rate limiting at origin. +# FASTEDGE_VAR_ENV_MAX_ATTEMPTS=5 + + +# --- Route prefix ----------------------------------------------------------- + +# The path prefix for all totp-app routes. Must match the CDN path rule that +# routes traffic to this app and must match otp-filter's AUTH_PREFIX so the +# filter bypasses these routes. Default: /auth/totp +# FASTEDGE_VAR_ENV_AUTH_PREFIX=/auth/totp + + +# --- Branding (optional; customize the hosted challenge/enroll pages) ------- + +# Brand name appended to the page and used as the logo alt text. +# FASTEDGE_VAR_ENV_TOTP_BRAND_NAME=MyApp + +# Logo image shown above the form (max 48 x 180 px). +# FASTEDGE_VAR_ENV_TOTP_BRAND_LOGO_URL=https://app.example.com/logo.svg + +# Favicon injected as . +# FASTEDGE_VAR_ENV_TOTP_BRAND_FAVICON_URL=https://app.example.com/favicon.ico + +# Button background + input focus-ring colour. Default: #0066cc. +# FASTEDGE_VAR_ENV_TOTP_BRAND_BUTTON_COLOR=#0066cc + +# Explicit button hover background. If unset, the button dims slightly on hover. +# FASTEDGE_VAR_ENV_TOTP_BRAND_BUTTON_HOVER_COLOR=#0052a3 + + +# ============================================================================ +# Profile B — ES256 one-time proof (opt-in; leave unset for Profile A) +# ============================================================================ +# Profile A (default): otp-filter enforces mfa_session on the protected CDN +# resource — zero origin code needed. +# Profile B: the origin verifies a one-time ES256 proof via this app's JWKS +# endpoint (/auth/totp/.well-known/jwks.json) and mints its own longer-lived +# session. Use when you need sessions longer than MFA_SESSION_TTL or origin- +# managed revocation. +# +# To enable Profile B, generate a P-256 keypair: +# node scripts/gen-ec-keypair.mjs +# then set MFA_PROOF_SIGNING_KEY and MFA_PROOF_PUBLIC_JWK below. +# ============================================================================ + +# ES256 private key (PKCS#8 PEM) for signing the one-time proof cookie. +# FASTEDGE_VAR_SECRET_MFA_PROOF_SIGNING_KEY=-----BEGIN PRIVATE KEY-----\nMIGH...\n-----END PRIVATE KEY----- + +# Corresponding public key as a JWK (JSON). Served at +# GET /auth/totp/.well-known/jwks.json for the origin to verify the proof. +# FASTEDGE_VAR_ENV_MFA_PROOF_PUBLIC_JWK={"kty":"EC","crv":"P-256","x":"...","y":"..."} + +# How long the one-time ES256 proof cookie is valid (seconds). Default: 90. +# Short TTL is intentional — the proof is single-use and immediately redeemed. +# FASTEDGE_VAR_ENV_PROOF_TTL=90 + +# Name of the one-time ES256 proof cookie. Default: mfa_proof. +# FASTEDGE_VAR_ENV_MFA_PROOF_COOKIE=mfa_proof diff --git a/edge-totp/otp-app/package.json b/edge-totp/otp-app/package.json new file mode 100644 index 0000000..16dac75 --- /dev/null +++ b/edge-totp/otp-app/package.json @@ -0,0 +1,21 @@ +{ + "name": "otp-app", + "description": "Edge TOTP MFA bolt-on for FastEdge", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "npx fastedge-build --input ./src/index.ts --output ./dist/totp-app.wasm --tsconfig ./tsconfig.json", + "test": "node --import=tsx/esm --test tests/unit/*.test.ts" + }, + "devDependencies": { + "@gcoredev/fastedge-sdk-js": "catalog:", + "@gcoredev/fastedge-test": "catalog:", + "tsx": "catalog:", + "typescript": "~5.9.3" + }, + "dependencies": { + "hono": "^4.9.8", + "jose": "catalog:", + "uqr": "^0.1.3" + } +} diff --git a/edge-totp/otp-app/registry.json b/edge-totp/otp-app/registry.json new file mode 100644 index 0000000..00ae375 --- /dev/null +++ b/edge-totp/otp-app/registry.json @@ -0,0 +1,217 @@ +{ + "name": "totp-app", + "api_type": "wasi-http", + "description": "Edge TOTP MFA bolt-on. Hosts the OTP challenge/verify UI and enrolment flow in front of an existing login. Pairs with totp-filter which enforces the mfa_session cookie on CDN resources.", + "params": [ + { + "name": "MFA_SESSION_KEY", + "data_type": "secret", + "mandatory": true, + "descr": "HS256 key used to sign the mfa_session JWT. Must be set on both totp-app (signs) and totp-filter (verifies) with the same value. Use a random string of 32+ bytes.", + "metadata": "{}" + }, + { + "name": "HANDOFF_KEY", + "data_type": "secret", + "mandatory": true, + "descr": "HS256 key shared between this app and the customer origin. The origin signs a short-lived handoff ticket after a successful password check; this app verifies it on /challenge and /verify.", + "metadata": "{}" + }, + { + "name": "KV_STORE_NAME", + "data_type": "string", + "mandatory": true, + "descr": "Name of the Gcore KV store that holds TOTP seeds. Create the store in the Gcore portal and pass its name here.", + "metadata": "{}" + }, + { + "name": "KV_STORE_ID", + "data_type": "string", + "mandatory": true, + "descr": "Numeric Gcore KV store ID (from the portal). Used by the /enroll endpoint to write seeds via the Gcore KV REST API.", + "metadata": "{}" + }, + { + "name": "ENROLL_API_KEY", + "data_type": "secret", + "mandatory": true, + "descr": "Bearer token that gates the POST /auth/totp/enroll endpoint. Pass it as Authorization: Bearer from your backend when provisioning TOTP seeds.", + "metadata": "{}" + }, + { + "name": "GCORE_API_TOKEN", + "data_type": "secret", + "mandatory": true, + "descr": "Gcore API token with KV write access. Used by /enroll to write seeds. Obtain from the Gcore portal under API Tokens.", + "metadata": "{}" + }, + { + "name": "MFA_AUDIENCE", + "data_type": "string", + "mandatory": false, + "descr": "Audience claim embedded in the mfa_session token (e.g. https://app.example.com). Required when pairing with totp-filter — the filter refuses every session unless its MFA_AUDIENCE matches. Must be identical on both apps.", + "metadata": "{}" + }, + { + "name": "MFA_ISSUER", + "data_type": "string", + "mandatory": false, + "descr": "Issuer claim embedded in the mfa_session token. Validated by totp-filter only when set on both apps. Defense-in-depth.", + "metadata": "{}" + }, + { + "name": "MFA_SESSION_COOKIE", + "data_type": "string", + "mandatory": false, + "descr": "Name of the cookie carrying the mfa_session token. Must be identical on both totp-app and totp-filter. Default: mfa_session.", + "metadata": "{\"default_value\":\"mfa_session\"}" + }, + { + "name": "MFA_SESSION_TTL", + "data_type": "string", + "mandatory": false, + "descr": "How long the mfa_session cookie lives, in seconds. Default: 28800 (8 hours).", + "metadata": "{\"default_value\":\"28800\"}" + }, + { + "name": "KV_KEY_PREFIX", + "data_type": "string", + "mandatory": false, + "descr": "Prefix prepended to userId for KV keys. Override only to share one KV store across multiple apps without key collisions. Default: totp:", + "metadata": "{\"default_value\":\"totp:\"}" + }, + { + "name": "ALLOW_SELF_ENROLLMENT", + "data_type": "string", + "mandatory": false, + "descr": "Whether an unenrolled user may self-enroll on first login via /auth/totp/activate. Default: true. Set to false for admin-provisioned deployments.", + "metadata": "{\"default_value\":\"true\"}" + }, + { + "name": "GCORE_API_URL", + "data_type": "string", + "mandatory": false, + "descr": "Gcore API base URL. Default: https://api.gcore.com. Override only in non-standard environments.", + "metadata": "{\"default_value\":\"https://api.gcore.com\"}" + }, + { + "name": "AUTH_PREFIX", + "data_type": "string", + "mandatory": false, + "descr": "Path prefix for all totp-app routes. Must match the CDN path rule that routes traffic to this app and totp-filter's AUTH_PREFIX. Default: /auth/totp", + "metadata": "{\"default_value\":\"/auth/totp\"}" + }, + { + "name": "TOTP_ISSUER", + "data_type": "string", + "mandatory": false, + "descr": "Issuer name shown in the user's authenticator app (Google Authenticator, Authy, etc.) next to the account. Default: TOTP", + "metadata": "{\"default_value\":\"TOTP\"}" + }, + { + "name": "TOTP_DIGITS", + "data_type": "string", + "mandatory": false, + "descr": "Number of digits in the OTP code. Default: 6. Most authenticators support 6 only.", + "metadata": "{\"default_value\":\"6\"}" + }, + { + "name": "TOTP_PERIOD", + "data_type": "string", + "mandatory": false, + "descr": "Time step in seconds. Default: 30. Must match the authenticator's setting.", + "metadata": "{\"default_value\":\"30\"}" + }, + { + "name": "TOTP_ALGORITHM", + "data_type": "string", + "mandatory": false, + "descr": "HMAC algorithm for TOTP. Default: SHA1. RFC 6238 mandates SHA1; most authenticators only support SHA1.", + "metadata": "{\"default_value\":\"SHA1\"}" + }, + { + "name": "TOTP_DRIFT", + "data_type": "string", + "mandatory": false, + "descr": "Clock-drift tolerance in ±steps. Default: 1 (accepts current ±one step). Increase only for deployments with known clock skew.", + "metadata": "{\"default_value\":\"1\"}" + }, + { + "name": "TICKET_TTL", + "data_type": "string", + "mandatory": false, + "descr": "How long the origin-signed handoff ticket is valid, in seconds. Default: 90. Should be short — the user is mid-login.", + "metadata": "{\"default_value\":\"90\"}" + }, + { + "name": "MAX_ATTEMPTS", + "data_type": "string", + "mandatory": false, + "descr": "Max failed /verify attempts per user per 5-minute window before locking out. Default: 5. PoP-local — defense-in-depth only.", + "metadata": "{\"default_value\":\"5\"}" + }, + { + "name": "TOTP_BRAND_NAME", + "data_type": "string", + "mandatory": false, + "descr": "Brand name appended to the page title and used as the logo alt text on the hosted challenge/enrol pages.", + "metadata": "{}" + }, + { + "name": "TOTP_BRAND_LOGO_URL", + "data_type": "string", + "mandatory": false, + "descr": "Logo image shown above the OTP form on the hosted pages. Recommended max: 48×180 px.", + "metadata": "{}" + }, + { + "name": "TOTP_BRAND_FAVICON_URL", + "data_type": "string", + "mandatory": false, + "descr": "Favicon injected as on the hosted pages.", + "metadata": "{}" + }, + { + "name": "TOTP_BRAND_BUTTON_COLOR", + "data_type": "string", + "mandatory": false, + "descr": "Button background and input focus-ring colour on the hosted pages. Default: #0066cc.", + "metadata": "{\"default_value\":\"#0066cc\"}" + }, + { + "name": "TOTP_BRAND_BUTTON_HOVER_COLOR", + "data_type": "string", + "mandatory": false, + "descr": "Explicit button hover background on the hosted pages. If unset, the button dims slightly on hover.", + "metadata": "{}" + }, + { + "name": "MFA_PROOF_SIGNING_KEY", + "data_type": "secret", + "mandatory": false, + "descr": "Profile B only. EC P-256 private key (PKCS#8 PEM) for signing the one-time ES256 proof cookie. Generate with: node scripts/gen-ec-keypair.mjs", + "metadata": "{}" + }, + { + "name": "MFA_PROOF_PUBLIC_JWK", + "data_type": "string", + "mandatory": false, + "descr": "Profile B only. Public counterpart of MFA_PROOF_SIGNING_KEY as JWK JSON. Served at GET /auth/totp/.well-known/jwks.json for the origin to verify the proof.", + "metadata": "{}" + }, + { + "name": "PROOF_TTL", + "data_type": "string", + "mandatory": false, + "descr": "Profile B only. How long the one-time ES256 proof cookie is valid, in seconds. Default: 90. Short TTL is intentional — the proof is single-use.", + "metadata": "{\"default_value\":\"90\"}" + }, + { + "name": "MFA_PROOF_COOKIE", + "data_type": "string", + "mandatory": false, + "descr": "Profile B only. Name of the one-time ES256 proof cookie. Default: mfa_proof.", + "metadata": "{\"default_value\":\"mfa_proof\"}" + } + ] +} diff --git a/edge-totp/otp-app/scripts/gen-ec-keypair.mjs b/edge-totp/otp-app/scripts/gen-ec-keypair.mjs new file mode 100644 index 0000000..94f172a --- /dev/null +++ b/edge-totp/otp-app/scripts/gen-ec-keypair.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * Generates a P-256 ECDSA keypair for the Profile-B ES256 one-time proof. + * + * Output: + * MFA_PROOF_SIGNING_KEY — private key as PKCS#8 PEM → FastEdge secret (otp-app signs the proof) + * MFA_PROOF_PUBLIC_JWK — public key as JWK JSON → FastEdge env var (otp-app serves it at the JWKS endpoint; the origin verifies the proof against it) + * + * Usage: + * node scripts/gen-ec-keypair.mjs + * node scripts/gen-ec-keypair.mjs --dotenv # emit .env-compatible lines + */ + +const { subtle } = globalThis.crypto; + +const pair = await subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], +); + +const pkcs8Der = await subtle.exportKey("pkcs8", pair.privateKey); +const pkcs8B64 = Buffer.from(pkcs8Der).toString("base64"); +const pemLines = pkcs8B64.match(/.{1,64}/g).join("\n"); +const pkcs8Pem = `-----BEGIN PRIVATE KEY-----\n${pemLines}\n-----END PRIVATE KEY-----`; + +const publicJwk = await subtle.exportKey("jwk", pair.publicKey); +// Remove key_ops / ext — not needed by JWKS consumers +delete publicJwk.key_ops; +delete publicJwk.ext; +const publicJwkJson = JSON.stringify(publicJwk); + +const dotenv = process.argv.includes("--dotenv"); + +if (dotenv) { + // Single-line PEM (newlines replaced with \n literal) for dotenv, with the + // FastEdge prefixes used in .env.example. + const pemOneLine = pkcs8Pem.replace(/\n/g, "\\n"); + console.log(`FASTEDGE_VAR_SECRET_MFA_PROOF_SIGNING_KEY=${pemOneLine}`); + console.log(`FASTEDGE_VAR_ENV_MFA_PROOF_PUBLIC_JWK=${publicJwkJson}`); +} else { + console.log("# ── MFA_PROOF_SIGNING_KEY (FastEdge secret — private, keep secure) ──"); + console.log(pkcs8Pem); + console.log(); + console.log("# ── MFA_PROOF_PUBLIC_JWK (FastEdge env var — served at the JWKS endpoint) ──"); + console.log(publicJwkJson); +} diff --git a/edge-totp/otp-app/src/challenge.ts b/edge-totp/otp-app/src/challenge.ts new file mode 100644 index 0000000..7c011f4 --- /dev/null +++ b/edge-totp/otp-app/src/challenge.ts @@ -0,0 +1,80 @@ +import { escapeHtml, brandingChrome } from "./lib/html.js"; + +function errorMessage(error: string): string { + switch (error) { + case "invalid": return "Incorrect code — please try again."; + case "locked": return "Too many failed attempts. Please restart."; + case "expired": return "Session expired. Please restart."; + default: return "Something went wrong. Please try again."; + } +} + +export function renderChallengePage(opts: { + formAction: string; + ticket: string; + error?: string; + digits?: number; + brandName?: string | null; + brandLogoUrl?: string | null; + brandFaviconUrl?: string | null; + brandButtonColor?: string | null; + brandButtonHoverColor?: string | null; +}): string { + const { formAction, ticket, error } = opts; + const digits = opts.digits ?? 6; + const errorHtml = error + ? `

${escapeHtml(errorMessage(error))}

` + : ""; + const { title, logoHtml, faviconHtml, btnColor, btnHoverCss } = + brandingChrome(opts, "Two-factor authentication"); + + return ` + + + + + + ${title} + ${faviconHtml} + + + + ${logoHtml} +

Two-factor authentication

+

Enter the ${digits}-digit code from your authenticator app.

+ ${errorHtml} +
+ + + +
+ +`; +} diff --git a/edge-totp/otp-app/src/config.ts b/edge-totp/otp-app/src/config.ts new file mode 100644 index 0000000..755945b --- /dev/null +++ b/edge-totp/otp-app/src/config.ts @@ -0,0 +1,97 @@ +import { getEnv } from "fastedge::env"; +import { getSecret } from "fastedge::secret"; +import { DEFAULT_KEY_PREFIX } from "./seed/kv.js"; +import { validateInt, parseBool } from "./lib/validate.js"; + +export interface Config { + authPrefix: string; + issuer: string; + digits: number; + period: number; + algorithm: string; + drift: number; + ticketTtl: number; + proofTtl: number; + mfaSessionTtl: number; + enrollTtl: number; + maxAttempts: number; + allowSelfEnrollment: boolean; + mfaSessionCookie: string; + mfaProofCookie: string; + mfaAudience: string | null; + mfaIssuer: string | null; + kvStoreName: string | null; + kvStoreId: string | null; + kvKeyPrefix: string; + gcoreApiUrl: string; + brandName: string | null; + brandLogoUrl: string | null; + brandFaviconUrl: string | null; + brandButtonColor: string | null; + brandButtonHoverColor: string | null; +} + +export interface Secrets { + handoffKey: string | null; + mfaSessionKey: string | null; + mfaProofSigningKey: string | null; + enrollApiKey: string | null; + gcoreApiToken: string | null; +} + +/** + * Parse an integer env var, applying `fallback` when unset/empty and rejecting + * anything that is not a whole number within [min, max]. We fail loudly rather + * than let `parseInt` silently coerce: `parseInt("6abc")` is 6 (hides a typo) + * and `parseInt("abc")` is NaN (turns into a confusing run-time misbehaviour — + * e.g. drift=NaN makes the verify loop never run and every code "fails"). A + * misconfigured edge app should surface the bad value, not degrade quietly. + */ +function intEnv( + name: string, + fallback: number, + bounds: { min?: number; max?: number } = {}, +): number { + return validateInt(name, getEnv(name), fallback, bounds); +} + +export function loadConfig(): Config { + const authPrefix = (getEnv("AUTH_PREFIX") ?? "/auth/totp").replace(/\/$/, ""); + return { + authPrefix, + issuer: getEnv("TOTP_ISSUER") ?? "TOTP", + digits: intEnv("TOTP_DIGITS", 6, { min: 6, max: 8 }), + period: intEnv("TOTP_PERIOD", 30, { min: 1, max: 300 }), + algorithm: getEnv("TOTP_ALGORITHM") ?? "SHA1", + drift: intEnv("TOTP_DRIFT", 1, { min: 0, max: 10 }), + ticketTtl: intEnv("TICKET_TTL", 90, { min: 1 }), + proofTtl: intEnv("PROOF_TTL", 90, { min: 1 }), + mfaSessionTtl: intEnv("MFA_SESSION_TTL", 28800, { min: 1 }), + enrollTtl: intEnv("ENROLL_TTL", 600, { min: 1 }), + maxAttempts: intEnv("MAX_ATTEMPTS", 5, { min: 1 }), + allowSelfEnrollment: parseBool(getEnv("ALLOW_SELF_ENROLLMENT"), true), + mfaSessionCookie: getEnv("MFA_SESSION_COOKIE") ?? "mfa_session", + mfaProofCookie: getEnv("MFA_PROOF_COOKIE") ?? "mfa_proof", + mfaAudience: getEnv("MFA_AUDIENCE") ?? null, + mfaIssuer: getEnv("MFA_ISSUER") ?? null, + kvStoreName: getEnv("KV_STORE_NAME"), + kvStoreId: getEnv("KV_STORE_ID"), + kvKeyPrefix: getEnv("KV_KEY_PREFIX") ?? DEFAULT_KEY_PREFIX, + gcoreApiUrl: getEnv("GCORE_API_URL") ?? "https://api.gcore.com", + brandName: getEnv("TOTP_BRAND_NAME") ?? null, + brandLogoUrl: getEnv("TOTP_BRAND_LOGO_URL") ?? null, + brandFaviconUrl: getEnv("TOTP_BRAND_FAVICON_URL") ?? null, + brandButtonColor: getEnv("TOTP_BRAND_BUTTON_COLOR") ?? null, + brandButtonHoverColor: getEnv("TOTP_BRAND_BUTTON_HOVER_COLOR") ?? null, + }; +} + +export function loadSecrets(): Secrets { + return { + handoffKey: getSecret("HANDOFF_KEY"), + mfaSessionKey: getSecret("MFA_SESSION_KEY"), + mfaProofSigningKey: getSecret("MFA_PROOF_SIGNING_KEY"), + enrollApiKey: getSecret("ENROLL_API_KEY"), + gcoreApiToken: getSecret("GCORE_API_TOKEN"), + }; +} diff --git a/edge-totp/otp-app/src/enroll.ts b/edge-totp/otp-app/src/enroll.ts new file mode 100644 index 0000000..4779a01 --- /dev/null +++ b/edge-totp/otp-app/src/enroll.ts @@ -0,0 +1,131 @@ +import { escapeHtml, brandingChrome } from "./lib/html.js"; + +function errorMessage(error: string): string { + switch (error) { + case "invalid": return "Incorrect code — please scan the QR code again and try once more."; + case "kv": return "Enrollment could not be saved. Please try again."; + default: return "Something went wrong. Please try again."; + } +} + +export function renderEnrollPage(opts: { + formAction: string; + svgQr: string; + error?: string; + digits?: number; + brandName?: string | null; + brandLogoUrl?: string | null; + brandFaviconUrl?: string | null; + brandButtonColor?: string | null; + brandButtonHoverColor?: string | null; +}): string { + const { formAction, svgQr, error } = opts; + const digits = opts.digits ?? 6; + const errorHtml = error + ? `

${escapeHtml(errorMessage(error))}

` + : ""; + const { title, logoHtml, faviconHtml, btnColor, btnHoverCss } = + brandingChrome(opts, "Activate two-factor authentication"); + + return ` + + + + + + ${title} + ${faviconHtml} + + + + ${logoHtml} +

Set up two-factor authentication

+

Protect your account with a one-time code from your phone.

+
    +
  1. Install an authenticator app — Google Authenticator, Authy, or similar.
  2. +
  3. Tap + or Add account and scan the QR code below.
  4. +
  5. Enter the ${digits}-digit code shown in the app to confirm.
  6. +
+
${svgQr}
+ ${errorHtml} + +
+ + + +
+ + +`; +} diff --git a/edge-totp/otp-app/src/index.ts b/edge-totp/otp-app/src/index.ts new file mode 100644 index 0000000..ef68489 --- /dev/null +++ b/edge-totp/otp-app/src/index.ts @@ -0,0 +1,545 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { getCookie } from "hono/cookie"; +import { getEnv } from "fastedge::env"; +import { Cache } from "fastedge::cache"; +import { loadConfig, loadSecrets } from "./config.js"; +import type { Config, Secrets } from "./config.js"; +import { + verifyHandoffTicket, + signMfaSession, + signProof, + signEnrollCookie, + verifyEnrollCookie, + buildJwks, + importEs256PrivateKey, + ticketFingerprint, +} from "./lib/jwt.js"; +import { findMatchingStep, generateSecret, otpauthUri } from "./lib/totp.js"; +import { timingSafeEqual } from "./lib/safeEqual.js"; +import { otpauthToSvg } from "./lib/qr.js"; +import { appendCookie } from "./lib/cookies.js"; +import { readSeed, writeSeed, isEnrolled } from "./seed/kv.js"; +import { validateRedirect } from "./lib/validate.js"; +import { renderChallengePage } from "./challenge.js"; +import { renderEnrollPage } from "./enroll.js"; + +// Issue the edge MFA cookies after a successful OTP: the HS256 mfa_session the +// Rust filter checks, plus (Profile B) a one-time ES256 proof for origins that +// verify via JWKS. Shared by /verify and /activate so the two success paths +// can't drift. Caller must ensure secrets.mfaSessionKey is set. +async function setMfaCookies( + c: Context, + cfg: Config, + secrets: Secrets, + userId: string, + logTag: string, +): Promise { + const claimOpts = { + ...(cfg.mfaAudience ? { aud: cfg.mfaAudience } : {}), + ...(cfg.mfaIssuer ? { iss: cfg.mfaIssuer } : {}), + }; + const sessionToken = await signMfaSession( + userId, + secrets.mfaSessionKey as string, + cfg.mfaSessionTtl, + claimOpts, + ); + appendCookie(c, cfg.mfaSessionCookie, sessionToken, { + maxAge: cfg.mfaSessionTtl, + }); + + // Profile B: mint a one-time ES256 proof if a signing key is configured. + // Delivered as a short-lived cookie — never in a URL. A signing failure is + // non-fatal: the Profile A session cookie above still stands. + if (secrets.mfaProofSigningKey) { + try { + const privateKey = await importEs256PrivateKey(secrets.mfaProofSigningKey); + const jti = crypto.randomUUID(); + const proof = await signProof(userId, privateKey, cfg.proofTtl, jti, claimOpts); + appendCookie(c, cfg.mfaProofCookie, proof, { maxAge: cfg.proofTtl }); + } catch (err) { + console.error(`[${logTag}] Profile B proof signing failed: ${err}`); + } + } +} + +function buildApp(authPrefix: string): Hono { + const app = new Hono(); + + // --- Health --- + app.get("/health", (c) => c.json({ ok: true })); + + // --- Enroll: POST {prefix}/enroll --- + app.post(`${authPrefix}/enroll`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + + const authHeader = c.req.header("authorization") ?? ""; + const key = authHeader.startsWith("Bearer ") + ? authHeader.slice(7) + : authHeader; + // Constant-time compare so a timing oracle can't be used to recover the + // enroll API key character by character. + if (!secrets.enrollApiKey || !timingSafeEqual(key, secrets.enrollApiKey)) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = (await c.req.json().catch(() => null)) as Record< + string, + unknown + > | null; + if (!body || typeof body.userId !== "string" || !body.userId) { + return c.json({ error: "userId required" }, 400); + } + const userId = body.userId as string; + const account = typeof body.account === "string" ? body.account : userId; + const force = body.force === true; + + if (!cfg.kvStoreName) + return c.json({ error: "KV_STORE_NAME not configured" }, 503); + if (!cfg.kvStoreId) + return c.json({ error: "KV_STORE_ID not configured" }, 503); + if (!secrets.gcoreApiToken) + return c.json({ error: "GCORE_API_TOKEN not configured" }, 503); + + // Guard against silent re-enrollment + if (!force && isEnrolled(cfg.kvStoreName, userId, cfg.kvKeyPrefix)) { + return c.json( + { error: "Already enrolled. Set force:true to re-enroll." }, + 409, + ); + } + + const seed = await generateSecret(); + const uri = otpauthUri(seed, account, cfg.issuer, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + }); + const svgQr = otpauthToSvg(uri); + + try { + await writeSeed( + cfg.gcoreApiUrl, + secrets.gcoreApiToken, + cfg.kvStoreId, + userId, + seed, + cfg.kvKeyPrefix, + ); + } catch (err) { + return c.json({ error: `KV write failed: ${err}` }, 502); + } + + return c.json({ userId, otpauthUri: uri, svgQr }); + }); + + // --- Challenge page: GET {prefix}/challenge?t= --- + app.get(`${authPrefix}/challenge`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + const ticket = c.req.query("t"); + const error = c.req.query("error"); + + if (!ticket) return c.text("Missing ticket", 400); + if (!secrets.handoffKey) return c.text("HANDOFF_KEY not configured", 503); + + const claims = await verifyHandoffTicket(ticket, secrets.handoffKey); + if (!claims) return c.text("Invalid or expired ticket", 400); + + // Not enrolled: send to self-service activation, or refuse when self-service + // enrollment is disabled (admin-provisioned deployments). + if (cfg.kvStoreName && !isEnrolled(cfg.kvStoreName, claims.sub, cfg.kvKeyPrefix)) { + if (!cfg.allowSelfEnrollment) { + return c.text("Not enrolled. Please contact your administrator.", 403); + } + return c.redirect( + `${authPrefix}/activate?t=${encodeURIComponent(ticket)}`, + 303, + ); + } + + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: error ?? undefined, + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }), + ); + }); + + // --- Self-service enrollment: GET {prefix}/activate?t= --- + // Generates a new TOTP seed, shows QR code, stores seed in a signed + // HttpOnly cookie (not written to KV until the user confirms the code). + app.get(`${authPrefix}/activate`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + if (!cfg.allowSelfEnrollment) + return c.text("Self-service enrollment is disabled.", 403); + const ticket = c.req.query("t"); + + if (!ticket) return c.text("Missing ticket", 400); + if (!secrets.handoffKey) return c.text("HANDOFF_KEY not configured", 503); + + const claims = await verifyHandoffTicket(ticket, secrets.handoffKey); + if (!claims) return c.text("Invalid or expired ticket", 400); + + // Already enrolled → skip to challenge + if (cfg.kvStoreName && isEnrolled(cfg.kvStoreName, claims.sub, cfg.kvKeyPrefix)) { + return c.redirect( + `${authPrefix}/challenge?t=${encodeURIComponent(ticket)}`, + 303, + ); + } + + const seed = await generateSecret(); + const uri = otpauthUri(seed, claims.sub, cfg.issuer, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + }); + const svgQr = otpauthToSvg(uri); + + // Store seed in a signed cookie — written to KV only after confirmation + const enrollToken = await signEnrollCookie( + claims.sub, + seed, + claims.next, + secrets.handoffKey, + cfg.enrollTtl, + ); + appendCookie(c, "totp_enroll", enrollToken, { maxAge: cfg.enrollTtl }); + + return c.html( + renderEnrollPage({ + formAction: `${authPrefix}/activate`, + svgQr, + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }), + ); + }); + + // --- Self-service enrollment confirm: POST {prefix}/activate --- + // Verifies the confirmation code, writes seed to KV, issues mfa_session. + app.post(`${authPrefix}/activate`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + + if (!cfg.allowSelfEnrollment) + return c.text("Self-service enrollment is disabled.", 403); + if (!secrets.handoffKey) return c.text("HANDOFF_KEY not configured", 503); + + const enrollToken = getCookie(c, "totp_enroll"); + if (!enrollToken) + return c.text( + "Enrollment session missing or expired — please log in again", + 400, + ); + + const enrollClaims = await verifyEnrollCookie( + enrollToken, + secrets.handoffKey, + ); + if (!enrollClaims) + return c.text( + "Enrollment session missing or expired — please log in again", + 400, + ); + + const { sub: userId, seed, next } = enrollClaims; + + // The enroll page submits the code as JSON via fetch(); a urlencoded + // fallback covers no-JS / direct posts. + const contentType = c.req.header("content-type") ?? ""; + const wantsJson = contentType.includes("application/json"); + let code = ""; + if (wantsJson) { + const parsed = (await c.req.json().catch(() => ({}))) as { + code?: unknown; + }; + code = + typeof parsed.code === "string" ? parsed.code.replace(/\s/g, "") : ""; + } else { + const params = new URLSearchParams(await c.req.text().catch(() => "")); + code = (params.get("code") ?? "").replace(/\s/g, ""); + } + + // Verify the confirmation code against the pending seed + const matchedStep = await findMatchingStep(code, seed, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + drift: cfg.drift, + }); + + if (matchedStep === null) { + if (wantsJson) { + return c.json({ message: "Incorrect code — please try again." }, 400); + } + const uri = otpauthUri(seed, userId, cfg.issuer, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + }); + return c.html( + renderEnrollPage({ + formAction: `${authPrefix}/activate`, + svgQr: otpauthToSvg(uri), + error: "invalid", + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }), + ); + } + + // Code confirmed — persist seed to KV + if (!cfg.kvStoreName) return c.text("KV_STORE_NAME not configured", 503); + if (!cfg.kvStoreId) return c.text("KV_STORE_ID not configured", 503); + if (!secrets.gcoreApiToken) + return c.text("GCORE_API_TOKEN not configured", 503); + + try { + await writeSeed( + cfg.gcoreApiUrl, + secrets.gcoreApiToken, + cfg.kvStoreId, + userId, + seed, + cfg.kvKeyPrefix, + ); + } catch (err) { + console.error(`[activate] KV write failed: ${err}`); + if (wantsJson) { + return c.json( + { message: "Enrollment could not be saved. Please try again." }, + 503, + ); + } + const uri = otpauthUri(seed, userId, cfg.issuer, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + }); + return c.html( + renderEnrollPage({ + formAction: `${authPrefix}/activate`, + svgQr: otpauthToSvg(uri), + error: "kv", + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }), + 503, + ); + } + + // Clear enrollment cookie + appendCookie(c, "totp_enroll", "", { maxAge: 0 }); + + // Issue mfa_session + optional Profile B proof (same as /verify) + if (!secrets.mfaSessionKey) + return c.text("MFA_SESSION_KEY not configured", 503); + await setMfaCookies(c, cfg, secrets, userId, "activate"); + + const target = validateRedirect(next); + // JSON path: the page's fetch() can't navigate the top window via a 303, + // so hand it the destination to navigate to. The mfa_session cookie set + // above rides along on this response. Fallback form posts get the 303. + if (wantsJson) { + return c.json({ next: target }); + } + return c.redirect(target, 303); + }); + + // --- Verify: POST {prefix}/verify --- + app.post(`${authPrefix}/verify`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + const branding = { + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }; + + // Accept both form and JSON + let ticket: string; + let code: string; + const ct = c.req.header("content-type") ?? ""; + if (ct.includes("application/json")) { + const body = (await c.req.json().catch(() => ({}))) as Record< + string, + unknown + >; + ticket = String(body.t ?? ""); + code = String(body.code ?? ""); + } else { + const params = new URLSearchParams(await c.req.text().catch(() => "")); + ticket = params.get("t") ?? ""; + code = params.get("code") ?? ""; + } + + if (!ticket || !code) return c.text("Missing t or code", 400); + if (!secrets.handoffKey) return c.text("HANDOFF_KEY not configured", 503); + + const claims = await verifyHandoffTicket(ticket, secrets.handoffKey); + if (!claims) { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "expired", + ...branding, + }), + 400, + ); + } + const { sub: userId, next } = claims; + + // SEC: single-use handoff ticket. The ticket is consumed on the first + // successful verify (marked below); presenting it again — a replay of the + // URL after a completed login — is refused here. POP-local best-effort, + // like the replay/brute-force guards (see R1/R2). + const ticketKey = `ticket:${await ticketFingerprint(ticket)}`; + if (await Cache.exists(ticketKey)) { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "expired", + ...branding, + }), + 400, + ); + } + + // Brute-force guard (POP-local; see R2) + const failKey = `fail:${userId}`; + const attempts = await Cache.incr(failKey); + if (attempts === 1) await Cache.expire(failKey, { ttl: 300 }); // 5-min window + if (attempts > cfg.maxAttempts) { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "locked", + ...branding, + }), + 429, + ); + } + + if (!cfg.kvStoreName) return c.text("KV_STORE_NAME not configured", 503); + const seed = readSeed(cfg.kvStoreName, userId, cfg.kvKeyPrefix); + if (!seed) return c.json({ error: "User not enrolled" }, 403); + + // Find which time-step matched, then check POP-local replay guard + const matchedStep = await findMatchingStep(code, seed, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + drift: cfg.drift, + }); + + if (matchedStep === null) { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "invalid", + ...branding, + }), + 200, + ); + } + + // Mark step used atomically (TTL covers the full drift acceptance window). + // incr is atomic, so two concurrent requests presenting the same valid code + // race here and exactly one wins (count === 1) — a plain exists()-then-set() + // check has a TOCTOU window that lets both through. + const replayKey = `used:${userId}:${matchedStep}`; + const replayTtl = cfg.period * (cfg.drift * 2 + 2); + const replayCount = await Cache.incr(replayKey); + if (replayCount === 1) { + await Cache.expire(replayKey, { ttl: replayTtl }); + } else { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "invalid", + ...branding, + }), + 200, + ); + } + + // A valid, non-replayed code clears the failure counter so a legitimate + // user's earlier mistakes don't accrue toward a later lockout. + await Cache.delete(failKey); + + if (!secrets.mfaSessionKey) + return c.text("MFA_SESSION_KEY not configured", 503); + + // Consume the ticket so it cannot be replayed after this success. TTL + // covers the handoff ticket's absolute max age (verifyHandoffTicket caps + // maxTokenAge at 10 min). POP-local best-effort like the guards above. + await Cache.set(ticketKey, "1", { ttl: 600 }); + + await setMfaCookies(c, cfg, secrets, userId, "verify"); + + return c.redirect(validateRedirect(next), 303); + }); + + // --- Logout: GET {prefix}/logout --- + // Clears edge cookies and redirects. The origin links here before (or instead + // of) its own logout so edge cookies are always cleared as part of the + // sign-out flow. + app.get(`${authPrefix}/logout`, (c) => { + const cfg = loadConfig(); + const dest = validateRedirect(c.req.query("redirect") ?? "/"); + appendCookie(c, cfg.mfaSessionCookie, "", { maxAge: 0 }); + appendCookie(c, cfg.mfaProofCookie, "", { maxAge: 0 }); + return c.redirect(dest, 302); + }); + + // --- JWKS: GET {prefix}/.well-known/jwks.json (Profile B) --- + app.get(`${authPrefix}/.well-known/jwks.json`, (c) => { + const publicJwk = getEnv("MFA_PROOF_PUBLIC_JWK"); + if (!publicJwk) return c.json({ error: "JWKS not configured" }, 503); + try { + return c.json(buildJwks(publicJwk)); + } catch { + return c.json({ error: "Invalid MFA_PROOF_PUBLIC_JWK" }, 500); + } + }); + + return app; +} + +addEventListener("fetch", (event: FetchEvent) => { + const cfg = loadConfig(); + const app = buildApp(cfg.authPrefix); + event.respondWith(app.fetch(event.request)); +}); diff --git a/edge-totp/otp-app/src/lib/base32.ts b/edge-totp/otp-app/src/lib/base32.ts new file mode 100644 index 0000000..957cbbe --- /dev/null +++ b/edge-totp/otp-app/src/lib/base32.ts @@ -0,0 +1,43 @@ +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function base32Decode(input: string): Uint8Array { + const s = input.toUpperCase().replace(/=+$/, "").replace(/\s/g, ""); + const bytes = new Uint8Array(Math.floor((s.length * 5) / 8)); + let buf = 0; + let bits = 0; + let idx = 0; + + for (let i = 0; i < s.length; i++) { + const val = ALPHABET.indexOf(s[i]); + if (val === -1) throw new Error(`Invalid base32 character: ${s[i]}`); + buf = (buf << 5) | val; + bits += 5; + if (bits >= 8) { + bits -= 8; + bytes[idx++] = (buf >> bits) & 0xff; + } + } + + return bytes.slice(0, idx); +} + +export function base32Encode(input: Uint8Array): string { + let out = ""; + let buf = 0; + let bits = 0; + + for (let i = 0; i < input.length; i++) { + buf = (buf << 8) | input[i]; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += ALPHABET[(buf >> bits) & 31]; + } + } + + if (bits > 0) { + out += ALPHABET[(buf << (5 - bits)) & 31]; + } + + return out; +} diff --git a/edge-totp/otp-app/src/lib/cookies.ts b/edge-totp/otp-app/src/lib/cookies.ts new file mode 100644 index 0000000..ad0ffba --- /dev/null +++ b/edge-totp/otp-app/src/lib/cookies.ts @@ -0,0 +1,26 @@ +import type { Context } from "hono"; + +/** + * Append a Set-Cookie header to a Hono context. + * Uses c.header(..., { append: true }) so multiple cookies can be set in one + * response. + */ +export function appendCookie( + c: Context, + name: string, + value: string, + opts: { + maxAge: number; + path?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Lax" | "Strict" | "None"; + }, +): void { + const path = opts.path ?? "/"; + const parts = [`${name}=${value}`, `Path=${path}`, `Max-Age=${opts.maxAge}`]; + if (opts.httpOnly !== false) parts.push("HttpOnly"); + if (opts.secure !== false) parts.push("Secure"); + parts.push(`SameSite=${opts.sameSite ?? "Lax"}`); + c.header("Set-Cookie", parts.join("; "), { append: true }); +} diff --git a/edge-totp/otp-app/src/lib/html.ts b/edge-totp/otp-app/src/lib/html.ts new file mode 100644 index 0000000..b792513 --- /dev/null +++ b/edge-totp/otp-app/src/lib/html.ts @@ -0,0 +1,51 @@ +// Shared HTML helpers for the hosted challenge/enroll pages. Kept in one place +// so the escaping used on every reflected value (branding, error text) has a +// single definition to audit, and the branding chrome can't drift between the +// two pages. + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +export interface Branding { + brandName?: string | null; + brandLogoUrl?: string | null; + brandFaviconUrl?: string | null; + brandButtonColor?: string | null; + brandButtonHoverColor?: string | null; +} + +export interface Chrome { + title: string; + logoHtml: string; + faviconHtml: string; + btnColor: string; + btnHoverCss: string; +} + +/** + * Derive the escaped, ready-to-interpolate branding fragments shared by both + * pages. `baseTitle` is the page-specific heading (e.g. "Two-factor + * authentication"); the brand name, when set, is appended after a separator. + */ +export function brandingChrome(b: Branding, baseTitle: string): Chrome { + return { + title: b.brandName + ? `${baseTitle} · ${escapeHtml(b.brandName)}` + : baseTitle, + logoHtml: b.brandLogoUrl + ? `` + : "", + faviconHtml: b.brandFaviconUrl + ? `` + : "", + btnColor: escapeHtml(b.brandButtonColor ?? "#0066cc"), + btnHoverCss: b.brandButtonHoverColor + ? `background: ${escapeHtml(b.brandButtonHoverColor)};` + : "filter: brightness(0.88);", + }; +} diff --git a/edge-totp/otp-app/src/lib/jwt.ts b/edge-totp/otp-app/src/lib/jwt.ts new file mode 100644 index 0000000..e360d34 --- /dev/null +++ b/edge-totp/otp-app/src/lib/jwt.ts @@ -0,0 +1,187 @@ +import { SignJWT, jwtVerify } from "jose"; + +function encodeUtf8(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +export async function importEs256PrivateKey(pem: string): Promise { + const b64 = pem + .replace(/-----BEGIN PRIVATE KEY-----/, "") + .replace(/-----END PRIVATE KEY-----/, "") + .replace(/\s+/g, ""); + const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey( + "pkcs8", + der, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"], + ); +} + +// --- Handoff ticket (HS256, signed by origin, verified by edge) --- + +export interface HandoffClaims { + sub: string; + next: string; +} + +export async function verifyHandoffTicket( + token: string, + secret: string, + opts: { iss?: string; aud?: string } = {}, +): Promise { + try { + const { payload } = await jwtVerify(token, encodeUtf8(secret), { + algorithms: ["HS256"], + // A handoff ticket MUST carry exp. jose validates exp only when present, + // so without this an origin that forgets to set exp would mint a ticket + // that never expires and can be replayed indefinitely (it rides in the + // URL). Require it and cap the absolute age as defence-in-depth. + requiredClaims: ["exp"], + maxTokenAge: "10 minutes", + ...(opts.iss ? { issuer: opts.iss } : {}), + ...(opts.aud ? { audience: opts.aud } : {}), + }); + // A handoff ticket is minted by the origin and never carries a seed. Reject + // any token that does — it would be an enroll cookie (same key, overlapping + // claims) being replayed through the handoff path. + if (typeof payload.sub !== "string" || typeof payload["next"] !== "string") { + return null; + } + if ("seed" in payload) return null; + return { sub: payload.sub, next: payload["next"] as string }; + } catch { + return null; + } +} + +/** + * Stable, unguessable fingerprint of a handoff ticket, derived from its + * signature segment. Used as a Cache key to enforce single-use: the signature + * is unique per minted ticket, so two distinct tickets never collide and the + * same ticket always maps to the same key. We hash it (rather than use the raw + * signature) to keep the cache key short and bounded. + */ +export async function ticketFingerprint(token: string): Promise { + const sig = token.slice(token.lastIndexOf(".") + 1); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(sig), + ); + const bytes = new Uint8Array(digest); + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, "0"); + } + return hex.slice(0, 32); +} + +// --- mfa_session (HS256, edge-internal: HTTP app ↔ Rust filter) --- + +export async function signMfaSession( + sub: string, + secret: string, + ttlSeconds: number, + opts: { iss?: string; aud?: string } = {}, +): Promise { + const now = Math.floor(Date.now() / 1000); + let builder = new SignJWT({ sub, amr: ["otp"] }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds); + if (opts.iss) builder = builder.setIssuer(opts.iss); + if (opts.aud) builder = builder.setAudience(opts.aud); + return builder.sign(encodeUtf8(secret)); +} + +// mfa_session is verified in production by the Rust filter (otp-filter), not +// here — the app only signs it. No TS verifier is kept to avoid a second, +// untested verification path drifting from the filter's. + +// --- ES256 one-time proof + JWKS (Profile B) --- + +export async function signProof( + sub: string, + privateKey: CryptoKey, + ttlSeconds: number, + jti: string, + opts: { iss?: string; aud?: string } = {}, +): Promise { + const now = Math.floor(Date.now() / 1000); + let builder = new SignJWT({ sub, amr: ["otp"], jti }) + .setProtectedHeader({ alg: "ES256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds); + if (opts.iss) builder = builder.setIssuer(opts.iss); + if (opts.aud) builder = builder.setAudience(opts.aud); + return builder.sign(privateKey); +} + +// --- Enrollment cookie (HS256, short-lived, carries pending seed + next) --- +// Written during GET /activate (before KV write); verified during POST /activate. +// Avoids persisting an unconfirmed seed to KV — the seed is only written after +// the user proves they scanned correctly. + +export interface EnrollClaims { + sub: string; + seed: string; + next: string; +} + +// The enroll cookie shares HANDOFF_KEY with the handoff ticket and has +// overlapping claims. A `purpose` marker (asserted on verify, and rejected on +// the handoff path) makes the two token types non-interchangeable. +const ENROLL_PURPOSE = "totp-enroll"; + +export async function signEnrollCookie( + sub: string, + seed: string, + next: string, + secret: string, + ttlSeconds: number, +): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub, seed, next, purpose: ENROLL_PURPOSE }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds) + .sign(encodeUtf8(secret)); +} + +export async function verifyEnrollCookie( + token: string, + secret: string, +): Promise { + try { + const { payload } = await jwtVerify(token, encodeUtf8(secret), { + algorithms: ["HS256"], + }); + if (payload["purpose"] !== ENROLL_PURPOSE) return null; + if ( + typeof payload.sub !== "string" || + typeof payload["seed"] !== "string" || + typeof payload["next"] !== "string" + ) return null; + return { + sub: payload.sub, + seed: payload["seed"] as string, + next: payload["next"] as string, + }; + } catch { + return null; + } +} + +// --- JWKS --- + +/** + * Build the JWKS response body from the pre-computed public JWK env var. + * exportKey is unavailable in the FastEdge runtime — the keypair is generated + * offline (scripts/gen-ec-keypair.mjs) and the public JWK stored in + * MFA_PROOF_PUBLIC_JWK (see storage-and-secrets.md). + */ +export function buildJwks(publicJwkJson: string): { keys: unknown[] } { + const parsed = JSON.parse(publicJwkJson); + return { keys: [parsed] }; +} diff --git a/edge-totp/otp-app/src/lib/qr.ts b/edge-totp/otp-app/src/lib/qr.ts new file mode 100644 index 0000000..addcec6 --- /dev/null +++ b/edge-totp/otp-app/src/lib/qr.ts @@ -0,0 +1,11 @@ +import { renderSVG } from "uqr"; + +/** + * Render an otpauth:// URI to an inline SVG string. + * uqr is pure-JS / SVG-string — no canvas or Node built-ins. + * Security: the otpauth URI embeds the seed — never send it to an + * external QR service. Render locally only. + */ +export function otpauthToSvg(uri: string): string { + return renderSVG(uri); +} diff --git a/edge-totp/otp-app/src/lib/safeEqual.ts b/edge-totp/otp-app/src/lib/safeEqual.ts new file mode 100644 index 0000000..3b4e778 --- /dev/null +++ b/edge-totp/otp-app/src/lib/safeEqual.ts @@ -0,0 +1,14 @@ +/** + * Constant-time string comparison. + * + * Returns early only on a length mismatch (string length is not secret here); + * otherwise compares every character so the time taken does not reveal how + * many leading characters matched. Used for comparing secrets — TOTP codes + * and the enroll API key — where a timing oracle would otherwise leak data. + */ +export function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} diff --git a/edge-totp/otp-app/src/lib/totp.ts b/edge-totp/otp-app/src/lib/totp.ts new file mode 100644 index 0000000..9ee5af3 --- /dev/null +++ b/edge-totp/otp-app/src/lib/totp.ts @@ -0,0 +1,108 @@ +import { base32Decode, base32Encode } from "./base32.js"; +import { timingSafeEqual } from "./safeEqual.js"; + +export interface TotpOptions { + digits?: number; + period?: number; + algorithm?: string; + drift?: number; +} + +function webCryptoHash(algorithm: string): string { + switch (algorithm.toUpperCase()) { + case "SHA256": return "SHA-256"; + case "SHA512": return "SHA-512"; + default: return "SHA-1"; // SHA1 is the RFC 6238 / authenticator-app default + } +} + +async function importSeed(seedBytes: Uint8Array, algorithm: string): Promise { + return crypto.subtle.importKey( + "raw", + seedBytes as unknown as ArrayBuffer, + { name: "HMAC", hash: webCryptoHash(algorithm) }, + false, + ["sign"], + ); +} + +/** + * Generate a TOTP code for a given seed and counter value. + * Exported for testing against known RFC 6238 vectors. + */ +export async function generateCode( + seed: string, + counter: number, + digits = 6, + algorithm = "SHA1", +): Promise { + const key = await importSeed(base32Decode(seed), algorithm); + + // 8-byte big-endian counter (RFC 4226 §5.3) + const buf = new ArrayBuffer(8); + const view = new DataView(buf); + view.setUint32(0, Math.floor(counter / 0x100000000), false); + view.setUint32(4, counter >>> 0, false); + + const hmac = new Uint8Array(await crypto.subtle.sign("HMAC", key, buf)); + + // RFC 4226 dynamic truncation + const offset = hmac[hmac.length - 1] & 0x0f; + const code = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + + return String(code % 10 ** digits).padStart(digits, "0"); +} + +/** + * Find the absolute step counter that matches code (within the drift window). + * Returns the matching step, or null. Used by the verify handler so it can + * record the exact step for POP-local replay guarding. + */ +export async function findMatchingStep( + code: string, + seed: string, + opts: TotpOptions = {}, +): Promise { + const digits = opts.digits ?? 6; + const period = opts.period ?? 30; + const algorithm = opts.algorithm ?? "SHA1"; + const drift = opts.drift ?? 1; + + const normalised = code.replace(/\s/g, ""); + if (normalised.length !== digits) return null; + + const counter = Math.floor(Date.now() / 1000 / period); + + for (let step = -drift; step <= drift; step++) { + const expected = await generateCode(seed, counter + step, digits, algorithm); + if (timingSafeEqual(normalised, expected)) return counter + step; + } + + return null; +} + +export async function generateSecret(): Promise { + const bytes = new Uint8Array(20); + crypto.getRandomValues(bytes); + return base32Encode(bytes); +} + +export function otpauthUri( + secret: string, + account: string, + issuer: string, + opts: { digits?: number; period?: number; algorithm?: string } = {}, +): string { + const params = new URLSearchParams({ + secret, + issuer, + digits: String(opts.digits ?? 6), + period: String(opts.period ?? 30), + algorithm: opts.algorithm ?? "SHA1", + }); + return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?${params}`; +} diff --git a/edge-totp/otp-app/src/lib/validate.ts b/edge-totp/otp-app/src/lib/validate.ts new file mode 100644 index 0000000..0296cf3 --- /dev/null +++ b/edge-totp/otp-app/src/lib/validate.ts @@ -0,0 +1,65 @@ +/** + * Only relative paths (starting with `/` but not `//` or `/\`) are allowed as + * redirect targets. Absolute URLs are rejected — same-host is required and the + * handoff ticket already came from the trusted origin. Anything else collapses + * to `/`, so a tampered/odd `next` can't become an open redirect. + */ +export function validateRedirect(next: string): string { + if ( + typeof next === "string" && + next.startsWith("/") && + !/^\/[\\/]/.test(next) + ) { + return next; + } + return "/"; +} + +/** + * Parse a boolean config value. Unset/empty returns `fallback`; `true/1/yes` + * and `false/0/no` (case-insensitive) map as expected; any other value falls + * back rather than guessing. Kept here (dependency-free) so it is unit-testable + * under plain Node alongside validateInt. + */ +export function parseBool( + raw: string | null | undefined, + fallback: boolean, +): boolean { + if (raw === undefined || raw === null || raw === "") return fallback; + const v = raw.trim().toLowerCase(); + if (v === "true" || v === "1" || v === "yes") return true; + if (v === "false" || v === "0" || v === "no") return false; + return fallback; +} + +/** + * Parse an integer config value, applying `fallback` when unset/empty and + * rejecting anything that is not a whole number within [min, max]. We fail + * loudly rather than let `parseInt` silently coerce: `parseInt("6abc")` is 6 + * (hides a typo) and `parseInt("abc")` is NaN (turns into a confusing run-time + * misbehaviour — e.g. drift=NaN makes the verify loop never run so every code + * "fails"). A misconfigured edge app should surface the bad value, not degrade + * quietly. + * + * Kept dependency-free (no `fastedge::*` imports) so it is unit-testable under + * plain Node. + */ +export function validateInt( + name: string, + raw: string | null | undefined, + fallback: number, + bounds: { min?: number; max?: number } = {}, +): number { + if (raw === undefined || raw === null || raw === "") return fallback; + const n = Number(raw); + if (!Number.isInteger(n)) { + throw new Error(`Config error: ${name}="${raw}" must be an integer`); + } + if (bounds.min !== undefined && n < bounds.min) { + throw new Error(`Config error: ${name}=${n} must be >= ${bounds.min}`); + } + if (bounds.max !== undefined && n > bounds.max) { + throw new Error(`Config error: ${name}=${n} must be <= ${bounds.max}`); + } + return n; +} diff --git a/edge-totp/otp-app/src/seed/kv.ts b/edge-totp/otp-app/src/seed/kv.ts new file mode 100644 index 0000000..6d1ae79 --- /dev/null +++ b/edge-totp/otp-app/src/seed/kv.ts @@ -0,0 +1,51 @@ +import { KvStore } from "fastedge::kv"; + +export const DEFAULT_KEY_PREFIX = "totp:"; + +/** + * Read a TOTP seed for a given userId from KV. + * Returns the base32-encoded seed string, or null if not enrolled. + */ +export function readSeed(storeName: string, userId: string, keyPrefix = DEFAULT_KEY_PREFIX): string | null { + const store = KvStore.open(storeName); + const buf = store.get(keyPrefix + userId); + if (!buf) return null; + return new TextDecoder().decode(buf); +} + +/** + * Write a TOTP seed for a given userId via the Gcore KV REST API. + * The fastedge::kv SDK is read-only — writes go through the management API. + * + * API: PUT /fastedge/v1/kv/{storeId}/data + * Body: array of { key, datatype, op, payload: { value, encoding } } entries. + * The seed is stored with encoding "plain" — see context/security/threat-model.md + * (R4) for the at-rest implications. + */ +export async function writeSeed( + apiUrl: string, + apiToken: string, + storeId: string, + userId: string, + seed: string, + keyPrefix = DEFAULT_KEY_PREFIX, +): Promise { + const url = `${apiUrl}/fastedge/v1/kv/${storeId}/data`; + const headers = { "Content-Type": "application/json", Authorization: `APIKey ${apiToken}` }; + const body = JSON.stringify([ + { key: keyPrefix + userId, datatype: "kv", op: "add", payload: { value: seed, encoding: "plain" } }, + ]); + const res = await fetch(url, { method: "PUT", headers, body }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`KV write failed: ${res.status} ${errBody}`); + } +} + +/** + * Check whether a user already has a seed enrolled. + * Used to guard against silent re-enrollment. + */ +export function isEnrolled(storeName: string, userId: string, keyPrefix = DEFAULT_KEY_PREFIX): boolean { + return readSeed(storeName, userId, keyPrefix) !== null; +} diff --git a/edge-totp/otp-app/tests/unit/base32.test.ts b/edge-totp/otp-app/tests/unit/base32.test.ts new file mode 100644 index 0000000..ac159d7 --- /dev/null +++ b/edge-totp/otp-app/tests/unit/base32.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { base32Decode, base32Encode } from "../../src/lib/base32.js"; + +// RFC 4648 §10 test vectors +test("base32Decode: empty string", () => { + assert.deepEqual(base32Decode(""), new Uint8Array(0)); +}); + +test("base32Decode: 'MY======' → 'f'", () => { + assert.deepEqual(base32Decode("MY======"), new Uint8Array([0x66])); +}); + +test("base32Decode: 'MZXQ====' → 'fo'", () => { + assert.deepEqual(base32Decode("MZXQ===="), new Uint8Array([0x66, 0x6f])); +}); + +test("base32Decode: 'MZXW6===' → 'foo'", () => { + assert.deepEqual(base32Decode("MZXW6==="), new Uint8Array([0x66, 0x6f, 0x6f])); +}); + +test("base32Decode: 'MZXW6YQ=' → 'foob'", () => { + assert.deepEqual(base32Decode("MZXW6YQ="), new Uint8Array([0x66, 0x6f, 0x6f, 0x62])); +}); + +test("base32Decode: 'MZXW6YTB' → 'fooba'", () => { + assert.deepEqual( + base32Decode("MZXW6YTB"), + new Uint8Array([0x66, 0x6f, 0x6f, 0x62, 0x61]), + ); +}); + +test("base32Decode: RFC 6238 SHA1 seed (ASCII '12345678901234567890')", () => { + const expected = new TextEncoder().encode("12345678901234567890"); + assert.deepEqual(base32Decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"), expected); +}); + +test("base32Decode: case-insensitive", () => { + assert.deepEqual(base32Decode("mfrgg==="), base32Decode("MFRGG===")); +}); + +test("base32Decode: strips whitespace", () => { + assert.deepEqual(base32Decode("MF RG G==="), base32Decode("MFRGG===")); +}); + +test("base32Decode: throws on invalid character", () => { + assert.throws(() => base32Decode("MFRG1==="), /Invalid base32 character/); +}); + +test("base32Encode + base32Decode: roundtrip", () => { + const input = new Uint8Array([0x00, 0xff, 0x10, 0xab, 0xcd, 0xef]); + assert.deepEqual(base32Decode(base32Encode(input)), input); +}); + +test("base32Encode: all-zero bytes", () => { + // 5 zero bytes → 8 'A' chars (5 * 8 bits = 40 bits = 8 * 5-bit groups, all zero) + assert.equal(base32Encode(new Uint8Array(5)), "AAAAAAAA"); +}); diff --git a/edge-totp/otp-app/tests/unit/jwt.test.ts b/edge-totp/otp-app/tests/unit/jwt.test.ts new file mode 100644 index 0000000..9ae3d82 --- /dev/null +++ b/edge-totp/otp-app/tests/unit/jwt.test.ts @@ -0,0 +1,210 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + verifyHandoffTicket, + signMfaSession, + signProof, + buildJwks, + importEs256PrivateKey, + signEnrollCookie, + verifyEnrollCookie, + ticketFingerprint, +} from "../../src/lib/jwt.js"; +import { SignJWT } from "jose"; + +const SECRET = "test-handoff-secret-32-bytes-long!"; + +function encodeUtf8(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +async function mintHandoffTicket( + sub: string, + next: string, + secret: string, + ttl = 90, +): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub, next }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + ttl) + .sign(encodeUtf8(secret)); +} + +// --- Handoff ticket --- + +test("verifyHandoffTicket: valid token returns claims", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET); + const claims = await verifyHandoffTicket(token, SECRET); + assert.equal(claims?.sub, "user1"); + assert.equal(claims?.next, "/dashboard"); +}); + +test("verifyHandoffTicket: wrong secret returns null", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET); + const claims = await verifyHandoffTicket(token, "wrong-secret"); + assert.equal(claims, null); +}); + +test("verifyHandoffTicket: expired token returns null", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET, -10); + const claims = await verifyHandoffTicket(token, SECRET); + assert.equal(claims, null); +}); + +test("verifyHandoffTicket: missing next claim returns null", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ sub: "user1" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + 90) + .sign(encodeUtf8(SECRET)); + const claims = await verifyHandoffTicket(token, SECRET); + assert.equal(claims, null); +}); + +test("verifyHandoffTicket: ticket without exp returns null", async () => { + const token = await new SignJWT({ sub: "user1", next: "/dashboard" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(Math.floor(Date.now() / 1000)) + .sign(encodeUtf8(SECRET)); + assert.equal(await verifyHandoffTicket(token, SECRET), null); +}); + +test("verifyHandoffTicket: ticket older than maxTokenAge returns null", async () => { + // iat 20 min ago but exp still in the future — only the maxTokenAge cap rejects it. + const old = Math.floor(Date.now() / 1000) - 20 * 60; + const token = await new SignJWT({ sub: "user1", next: "/dashboard" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(old) + .setExpirationTime(old + 60 * 60) + .sign(encodeUtf8(SECRET)); + assert.equal(await verifyHandoffTicket(token, SECRET), null); +}); + +test("verifyHandoffTicket: token carrying a seed claim returns null", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ sub: "user1", next: "/", seed: "JBSWY3DP" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + 90) + .sign(encodeUtf8(SECRET)); + assert.equal(await verifyHandoffTicket(token, SECRET), null); +}); + +// --- enroll cookie (purpose binding) --- + +test("signEnrollCookie + verifyEnrollCookie: roundtrip", async () => { + const token = await signEnrollCookie("user1", "JBSWY3DP", "/dash", SECRET, 600); + const claims = await verifyEnrollCookie(token, SECRET); + assert.equal(claims?.sub, "user1"); + assert.equal(claims?.seed, "JBSWY3DP"); + assert.equal(claims?.next, "/dash"); +}); + +test("verifyEnrollCookie: token without enroll purpose returns null", async () => { + // A handoff-shaped token that happens to carry a seed must not pass as an enroll cookie. + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ sub: "user1", next: "/", seed: "JBSWY3DP" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + 600) + .sign(encodeUtf8(SECRET)); + assert.equal(await verifyEnrollCookie(token, SECRET), null); +}); + +// --- mfa_session (verified in production by the Rust filter, not in TS) --- + +test("signMfaSession: emits HS256 token with sub/amr/exp/aud/iss", async () => { + const token = await signMfaSession("alice", SECRET, 3600, { + iss: "totp-app", + aud: "myapp", + }); + const [headerB64, payloadB64] = token.split("."); + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); + const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString()); + assert.equal(header.alg, "HS256"); + assert.equal(payload.sub, "alice"); + assert.deepEqual(payload.amr, ["otp"]); + assert.equal(payload.iss, "totp-app"); + assert.equal(payload.aud, "myapp"); + assert.ok(payload.exp > payload.iat); +}); + +// --- ticketFingerprint (single-use ticket key derivation) --- + +test("ticketFingerprint: stable for the same token", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET); + assert.equal(await ticketFingerprint(token), await ticketFingerprint(token)); +}); + +test("ticketFingerprint: differs for distinct tokens", async () => { + const a = await mintHandoffTicket("user1", "/dashboard", SECRET); + const b = await mintHandoffTicket("user2", "/dashboard", SECRET); + assert.notEqual(await ticketFingerprint(a), await ticketFingerprint(b)); +}); + +test("ticketFingerprint: bounded-length lowercase hex", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET); + const fp = await ticketFingerprint(token); + assert.equal(fp.length, 32); + assert.match(fp, /^[0-9a-f]+$/); +}); + +// --- buildJwks --- + +test("buildJwks: wraps parsed JWK in keys array", () => { + const jwk = { kty: "EC", crv: "P-256", x: "abc", y: "def", use: "sig" }; + const result = buildJwks(JSON.stringify(jwk)); + assert.deepEqual(result, { keys: [jwk] }); +}); + +test("buildJwks: throws on invalid JSON", () => { + assert.throws(() => buildJwks("not json"), /SyntaxError|Unexpected/); +}); + +// --- ES256 proof (requires an actual keypair) --- + +test("signProof: produces a JWT signed with ES256", async () => { + // Generate a test keypair using the Web Crypto API (Node has this) + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + + const token = await signProof("bob", keyPair.privateKey, 90, "jti-abc", { + iss: "totp-app", + aud: "myapp", + }); + + // Decode header to confirm ES256 alg + const [headerB64] = token.split("."); + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); + assert.equal(header.alg, "ES256"); + + // Decode payload to confirm claims + const [, payloadB64] = token.split("."); + const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString()); + assert.equal(payload.sub, "bob"); + assert.equal(payload.jti, "jti-abc"); + assert.deepEqual(payload.amr, ["otp"]); +}); + +test("importEs256PrivateKey: accepts PKCS8 PEM from gen-ec-keypair.mjs format", async () => { + // Generate + export a key pair to produce a real PKCS8 PEM for testing + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const pkcs8Der = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey); + const b64 = Buffer.from(pkcs8Der).toString("base64"); + const lines = b64.match(/.{1,64}/g)!.join("\n"); + const pem = `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; + + // importEs256PrivateKey must accept this PEM without throwing + const imported = await importEs256PrivateKey(pem); + assert.equal(typeof imported, "object"); +}); diff --git a/edge-totp/otp-app/tests/unit/totp.test.ts b/edge-totp/otp-app/tests/unit/totp.test.ts new file mode 100644 index 0000000..1f01ec7 --- /dev/null +++ b/edge-totp/otp-app/tests/unit/totp.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { generateCode, otpauthUri } from "../../src/lib/totp.js"; + +// RFC 6238 Appendix B test vectors. +// Seed: ASCII "12345678901234567890" = base32 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ +// Algorithm: SHA1 (authenticator-app default), 8-digit, period=30, T0=0 +const RFC_SEED = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + +function counter(unixTime: number, period = 30): number { + return Math.floor(unixTime / period); +} + +test("RFC 6238 vector T=59 → 8-digit 94287082", async () => { + const code = await generateCode(RFC_SEED, counter(59), 8); + assert.equal(code, "94287082"); +}); + +test("RFC 6238 vector T=1111111109 → 8-digit 07081804", async () => { + const code = await generateCode(RFC_SEED, counter(1111111109), 8); + assert.equal(code, "07081804"); +}); + +test("RFC 6238 vector T=1234567890 → 8-digit 89005924", async () => { + const code = await generateCode(RFC_SEED, counter(1234567890), 8); + assert.equal(code, "89005924"); +}); + +test("RFC 6238 vector T=2000000000 → 8-digit 69279037", async () => { + const code = await generateCode(RFC_SEED, counter(2000000000), 8); + assert.equal(code, "69279037"); +}); + +// 6-digit = rightmost 6 of the 8-digit code +test("RFC 6238 vector T=59 → 6-digit 287082", async () => { + const code = await generateCode(RFC_SEED, counter(59), 6); + assert.equal(code, "287082"); +}); + +test("RFC 6238 vector T=1111111109 → 6-digit 081804", async () => { + const code = await generateCode(RFC_SEED, counter(1111111109), 6); + assert.equal(code, "081804"); +}); + +test("RFC 6238 vector T=1234567890 → 6-digit 005924", async () => { + const code = await generateCode(RFC_SEED, counter(1234567890), 6); + assert.equal(code, "005924"); +}); + +test("RFC 6238 vector T=2000000000 → 6-digit 279037", async () => { + const code = await generateCode(RFC_SEED, counter(2000000000), 6); + assert.equal(code, "279037"); +}); + +// Drift: same counter at adjacent step +test("adjacent counter produces a different code", async () => { + const c = counter(1111111109); + const a = await generateCode(RFC_SEED, c, 6); + const b = await generateCode(RFC_SEED, c + 1, 6); + assert.notEqual(a, b); +}); + +test("otpauthUri: produces correct scheme and query params", () => { + const uri = otpauthUri("JBSWY3DPEHPK3PXP", "alice@example.com", "Acme", { + digits: 6, + period: 30, + algorithm: "SHA1", + }); + assert.ok(uri.startsWith("otpauth://totp/"), `unexpected scheme: ${uri}`); + assert.ok(uri.includes("secret=JBSWY3DPEHPK3PXP"), `missing secret: ${uri}`); + assert.ok(uri.includes("issuer=Acme"), `missing issuer: ${uri}`); + assert.ok(uri.includes("digits=6"), `missing digits: ${uri}`); + assert.ok(uri.includes("period=30"), `missing period: ${uri}`); +}); diff --git a/edge-totp/otp-app/tests/unit/validate.test.ts b/edge-totp/otp-app/tests/unit/validate.test.ts new file mode 100644 index 0000000..d2b9ded --- /dev/null +++ b/edge-totp/otp-app/tests/unit/validate.test.ts @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validateInt, validateRedirect, parseBool } from "../../src/lib/validate.js"; + +test("validateRedirect: keeps a same-host relative path", () => { + assert.equal(validateRedirect("/dashboard"), "/dashboard"); + assert.equal(validateRedirect("/"), "/"); + assert.equal(validateRedirect("/path?x=1#h"), "/path?x=1#h"); +}); + +test("validateRedirect: rejects protocol-relative and backslash tricks", () => { + assert.equal(validateRedirect("//evil.com"), "/"); + assert.equal(validateRedirect("/\\evil.com"), "/"); +}); + +test("validateRedirect: rejects absolute URLs and non-paths", () => { + assert.equal(validateRedirect("https://evil.com"), "/"); + assert.equal(validateRedirect("javascript:alert(1)"), "/"); + assert.equal(validateRedirect(""), "/"); +}); + +test("validateInt: unset/empty falls back to default", () => { + assert.equal(validateInt("X", undefined, 30), 30); + assert.equal(validateInt("X", null, 30), 30); + assert.equal(validateInt("X", "", 30), 30); +}); + +test("validateInt: parses a valid integer", () => { + assert.equal(validateInt("X", "45", 30), 45); +}); + +test("validateInt: rejects non-integer (no silent parseInt coercion)", () => { + // parseInt("6abc") would be 6 — we must reject it instead of hiding the typo. + assert.throws(() => validateInt("TOTP_DIGITS", "6abc", 6), /must be an integer/); + assert.throws(() => validateInt("TOTP_DRIFT", "abc", 1), /must be an integer/); + assert.throws(() => validateInt("TOTP_PERIOD", "1.5", 30), /must be an integer/); +}); + +test("validateInt: enforces min bound", () => { + assert.throws(() => validateInt("TOTP_DIGITS", "4", 6, { min: 6 }), /must be >= 6/); +}); + +test("validateInt: enforces max bound", () => { + assert.throws(() => validateInt("TOTP_DIGITS", "9", 6, { min: 6, max: 8 }), /must be <= 8/); +}); + +test("validateInt: accepts values on the boundary", () => { + assert.equal(validateInt("TOTP_DIGITS", "6", 6, { min: 6, max: 8 }), 6); + assert.equal(validateInt("TOTP_DIGITS", "8", 6, { min: 6, max: 8 }), 8); +}); + +test("parseBool: unset/empty falls back to default", () => { + assert.equal(parseBool(undefined, true), true); + assert.equal(parseBool(null, true), true); + assert.equal(parseBool("", false), false); +}); + +test("parseBool: recognises truthy and falsy spellings (case-insensitive)", () => { + for (const v of ["true", "TRUE", "1", "yes", " Yes "]) assert.equal(parseBool(v, false), true); + for (const v of ["false", "FALSE", "0", "no", " No "]) assert.equal(parseBool(v, true), false); +}); + +test("parseBool: unrecognised value falls back rather than guessing", () => { + assert.equal(parseBool("maybe", true), true); + assert.equal(parseBool("maybe", false), false); +}); diff --git a/edge-totp/otp-app/tsconfig.json b/edge-totp/otp-app/tsconfig.json new file mode 100644 index 0000000..c678d62 --- /dev/null +++ b/edge-totp/otp-app/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2023"], + "types": ["@gcoredev/fastedge-sdk-js"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/edge-totp/otp-filter/.cargo/config.toml b/edge-totp/otp-filter/.cargo/config.toml new file mode 100755 index 0000000..dc0be73 --- /dev/null +++ b/edge-totp/otp-filter/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" \ No newline at end of file diff --git a/edge-totp/otp-filter/.env.example b/edge-totp/otp-filter/.env.example new file mode 100644 index 0000000..a6bba48 --- /dev/null +++ b/edge-totp/otp-filter/.env.example @@ -0,0 +1,61 @@ +# ============================================================================ +# otp-filter — TOTP CDN filter configuration +# ============================================================================ +# Copy this file to .env and fill in real values. .env is git-ignored — never +# commit real secrets. +# +# Two prefixes map to two places in the Gcore portal: +# FASTEDGE_VAR_ENV_ -> Environment Variables (non-sensitive config) +# FASTEDGE_VAR_SECRET_ -> Secret Manager (sensitive values) +# +# This filter runs in the CDN proxy in front of the protected resource. On every +# request it verifies the mfa_session cookie (HS256) and either passes the +# request through or redirects/denies. It pairs with otp-app. +# +# ---------------------------------------------------------------------------- +# Keys shared with otp-app — these MUST match otp-app's .env +# ---------------------------------------------------------------------------- +# MFA_SESSION_KEY otp-app signs the mfa_session token with this key +# MFA_SESSION_COOKIE must equal otp-app's MFA_SESSION_COOKIE +# AUTH_PREFIX must equal otp-app's AUTH_PREFIX +# MFA_AUDIENCE must equal otp-app's MFA_AUDIENCE +# MFA_ISSUER (optional) only checked when set on BOTH sides +# ============================================================================ + + +# --- Token verification (HS256) -------------------------------------------- + +# REQUIRED. HS256 key used to verify the mfa_session JWT signed by otp-app. +# Must be the same value as MFA_SESSION_KEY in otp-app. +FASTEDGE_VAR_SECRET_MFA_SESSION_KEY=change-me-to-a-long-random-string + + +# --- Token binding (audience / issuer) ------------------------------------- + +# REQUIRED and fail-closed. The filter refuses EVERY session unless this is +# set and equals the token's `aud`. Must match MFA_AUDIENCE in otp-app. +# (otp-app must also be configured to embed this value — see its MFA_AUDIENCE.) +FASTEDGE_VAR_ENV_MFA_AUDIENCE=https://app.example.com + +# OPTIONAL. When set, the token's `iss` must match. Only meaningful if +# otp-app also sets MFA_ISSUER. Leave unset to skip the issuer check. +# FASTEDGE_VAR_ENV_MFA_ISSUER=https://auth.example.com + + +# --- Request handling ------------------------------------------------------- + +# Cookie name to read the mfa_session token from. Must match otp-app. +# Default: mfa_session +FASTEDGE_VAR_ENV_MFA_SESSION_COOKIE=mfa_session + +# Where to bypass — the totp-app path prefix (challenge, verify, enroll, JWKS). +# Must match otp-app's AUTH_PREFIX so the login flow can complete. +# Default: /auth/totp +# FASTEDGE_VAR_ENV_AUTH_PREFIX=/auth/totp + +# Where to send unauthenticated users. This should be a URL on the customer +# origin that initiates the MFA flow: verifies the password session, signs a +# handoff ticket, and 303-redirects to /auth/totp/challenge?t=. +# The original URL is appended as ?redirect= for post-MFA return. +# If unset, denied requests get a 401 instead of a redirect (useful for APIs). +FASTEDGE_VAR_ENV_MFA_LOGIN_URL=https://app.example.com/login/mfa diff --git a/edge-totp/otp-filter/Cargo.lock b/edge-totp/otp-filter/Cargo.lock new file mode 100644 index 0000000..dc8e446 --- /dev/null +++ b/edge-totp/otp-filter/Cargo.lock @@ -0,0 +1,650 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastedge" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d665bf3e9be79d0be271c1fd4233c752d0c52f0e48c8e4e8cdb6da6ea6915a6a" +dependencies = [ + "bytes", + "fastedge-derive", + "http", + "mime", + "thiserror", + "wit-bindgen", +] + +[[package]] +name = "fastedge-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddaea791529e71281550aa22e30597e23362039f7027fe7f9db5d55a16c338ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proxy-wasm" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de8f6564bd52c2f4ff79fa5d1bd3bc10d8f822162af8d527e121e46703496aa0" +dependencies = [ + "hashbrown 0.16.1", + "log", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "totp-filter" +version = "0.1.0" +dependencies = [ + "base64", + "fastedge", + "hmac", + "proxy-wasm", + "serde_json", + "sha2", + "urlencoding", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +dependencies = [ + "bitflags", + "futures", + "once_cell", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/edge-totp/otp-filter/Cargo.toml b/edge-totp/otp-filter/Cargo.toml new file mode 100644 index 0000000..65f8d4e --- /dev/null +++ b/edge-totp/otp-filter/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "totp-filter" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2.1" +fastedge = { version = "0.4", default-features = false, features = ["proxywasm"] } +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +serde_json = "1.0" +urlencoding = "2.1.2" + +[profile.release] +opt-level = "s" +lto = true diff --git a/edge-totp/otp-filter/package.json b/edge-totp/otp-filter/package.json new file mode 100644 index 0000000..c1721fc --- /dev/null +++ b/edge-totp/otp-filter/package.json @@ -0,0 +1,16 @@ +{ + "name": "otp-filter", + "description": "Edge MFA enforcement filter (Rust proxy-wasm) — black-box wasm tests", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "cargo build --release", + "test": "FASTEDGE_RUN_PATH=./node_modules/@gcoredev/fastedge-test/dist/fastedge-cli/fastedge-run-linux-x64 node --import=tsx/esm tests/filter.test.ts" + }, + "devDependencies": { + "@gcoredev/fastedge-test": "catalog:", + "jose": "catalog:", + "tsx": "catalog:" + } +} diff --git a/edge-totp/otp-filter/registry.json b/edge-totp/otp-filter/registry.json new file mode 100644 index 0000000..160f0f7 --- /dev/null +++ b/edge-totp/otp-filter/registry.json @@ -0,0 +1,49 @@ +{ + "name": "totp-filter", + "api_type": "proxy-wasm", + "description": "Edge TOTP MFA enforcement filter. Verifies the mfa_session cookie (HS256) on every CDN request and redirects or denies unauthenticated users. Pairs with totp-app which issues the session after a successful OTP challenge.", + "params": [ + { + "name": "MFA_SESSION_KEY", + "data_type": "secret", + "mandatory": true, + "descr": "HS256 key used to verify the mfa_session JWT signed by totp-app. Must be set on both totp-app (signs) and totp-filter (verifies) with the same value.", + "metadata": "{}" + }, + { + "name": "MFA_AUDIENCE", + "data_type": "string", + "mandatory": true, + "descr": "Required and fail-closed. The filter refuses every session unless this is set and equals the token's aud claim. Must match MFA_AUDIENCE on totp-app (e.g. https://app.example.com).", + "metadata": "{}" + }, + { + "name": "MFA_ISSUER", + "data_type": "string", + "mandatory": false, + "descr": "When set, the token's iss claim must match. Only meaningful if totp-app also sets MFA_ISSUER. Leave unset to skip the issuer check.", + "metadata": "{}" + }, + { + "name": "MFA_SESSION_COOKIE", + "data_type": "string", + "mandatory": false, + "descr": "Cookie name to read the mfa_session token from. Must match totp-app. Default: mfa_session.", + "metadata": "{\"default_value\":\"mfa_session\"}" + }, + { + "name": "AUTH_PREFIX", + "data_type": "string", + "mandatory": false, + "descr": "Path prefix the filter bypasses so the login flow can complete. Must match totp-app's AUTH_PREFIX and the CDN path rule routing to totp-app. Default: /auth/totp", + "metadata": "{\"default_value\":\"/auth/totp\"}" + }, + { + "name": "MFA_LOGIN_URL", + "data_type": "string", + "mandatory": false, + "descr": "Where to redirect unauthenticated users. Should be a URL on the customer origin that validates the password session and redirects to /auth/totp/challenge?t=. If unset, denied requests get a 401 instead of a redirect.", + "metadata": "{}" + } + ] +} diff --git a/edge-totp/otp-filter/src/lib.rs b/edge-totp/otp-filter/src/lib.rs new file mode 100644 index 0000000..79da52f --- /dev/null +++ b/edge-totp/otp-filter/src/lib.rs @@ -0,0 +1,258 @@ +use std::env; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use fastedge::proxywasm::secret; +use hmac::{Hmac, Mac}; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; +use sha2::Sha256; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Info); + proxy_wasm::set_root_context(|_| -> Box { Box::new(OtpGuardRoot) }); +}} + +pub struct OtpGuardRoot; + +impl Context for OtpGuardRoot {} + +impl RootContext for OtpGuardRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(OtpGuard)) + } +} + +struct OtpGuard; + +impl Context for OtpGuard {} + +const DEFAULT_COOKIE: &str = "mfa_session"; +const DEFAULT_AUTH_PREFIX: &str = "/auth/totp"; +const EXPECTED_ALG: &str = "HS256"; + +impl HttpContext for OtpGuard { + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + let full_path = self + .get_property(vec!["request.path"]) + .and_then(|v| String::from_utf8(v).ok()) + .unwrap_or_default(); + + let auth_prefix = env::var("AUTH_PREFIX") + .unwrap_or_else(|_| DEFAULT_AUTH_PREFIX.to_string()); + + // Bypass the totp-app paths (challenge, verify, enroll, JWKS) and /health. + // Gating these would redirect-loop the TOTP flow. + if is_bypass_path(&full_path, &auth_prefix) { + return Action::Continue; + } + + let cookie_name = env::var("MFA_SESSION_COOKIE") + .unwrap_or_else(|_| DEFAULT_COOKIE.to_string()); + let login_url = env::var("MFA_LOGIN_URL").unwrap_or_default(); + + let cookie_header = self.get_http_request_header("cookie").unwrap_or_default(); + let token = match extract_cookie(&cookie_header, &cookie_name) { + Some(t) => t, + None => return self.deny(&full_path, &login_url, "no mfa_session cookie"), + }; + + let claims = match verify_jwt(&token) { + Err(reason) => return self.deny(&full_path, &login_url, reason), + Ok(c) => c, + }; + + // MFA_AUDIENCE is required. Fail-closed if not configured — a filter + // with no audience set cannot know which tokens belong to it, so it refuses + // every session rather than trusting any signed token indiscriminately. + let expected_aud = env::var("MFA_AUDIENCE").unwrap_or_default(); + if expected_aud.is_empty() { + return self.deny( + &full_path, + &login_url, + "MFA_AUDIENCE not configured — refusing all sessions (set MFA_AUDIENCE to match otp-app's MFA_AUDIENCE)", + ); + } + if !aud_matches(&claims.aud, &expected_aud) { + return self.deny(&full_path, &login_url, "aud mismatch"); + } + + // MFA_ISSUER is optional — validated only when set. + if let Ok(expected) = env::var("MFA_ISSUER") { + if !expected.is_empty() && claims.iss.as_deref() != Some(expected.as_str()) { + return self.deny(&full_path, &login_url, "iss mismatch"); + } + } + + println!( + "otp_guard: authorized — valid mfa_session (sub={}) for {full_path}", + claims.sub.as_deref().unwrap_or("") + ); + + Action::Continue + } +} + +impl OtpGuard { + fn deny(&mut self, full_path: &str, login_url: &str, reason: &str) -> Action { + println!("otp_guard: denied — {reason}"); + if login_url.is_empty() { + // No redirect configured: fail-closed with 401. + self.send_http_response( + 401, + vec![("content-type", "text/plain")], + Some(b"MFA required"), + ); + return Action::Pause; + } + // Encode the return target as the *relative* request path only. + // Building it from request.scheme/request.host would fold an + // attacker-controllable Host header into the redirect= value, which a + // login page that bounces back to `redirect` could turn into an open + // redirect. A relative path is inherently same-origin and matches the + // app's own same-host redirect policy (validateRedirect). + let encoded = urlencoding::encode(full_path); + let sep = if login_url.contains('?') { "&" } else { "?" }; + let location = format!("{login_url}{sep}redirect={encoded}"); + self.send_http_response(302, vec![("location", &location)], Some(b"")); + Action::Pause + } +} + +fn is_bypass_path(full_path: &str, auth_prefix: &str) -> bool { + let path = full_path.split('?').next().unwrap_or(full_path); + // Never bypass a path that carries traversal or encoded-separator + // sequences. `/auth/totp/../admin` matches the prefix below but the origin + // may normalise it back to `/admin`, slipping a protected resource past the + // gate. If the path looks abnormal, fall through to the cookie check so it + // is gated rather than waved through. + if has_suspicious_sequence(path) { + return false; + } + path == "/health" + || path == auth_prefix + || path.starts_with(&format!("{auth_prefix}/")) +} + +/// Detect path segments that could be normalised by the origin into a +/// different resource: `..`, `//`, backslashes, or percent-encoded dots and +/// slashes (`%2e`, `%2f`, including double-encoding via `%25`). +fn has_suspicious_sequence(path: &str) -> bool { + if path.contains("..") || path.contains("//") || path.contains('\\') { + return true; + } + let lower = path.to_ascii_lowercase(); + lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") || lower.contains("%25") +} + +fn extract_cookie(header: &str, name: &str) -> Option { + let needle = format!("{name}="); + for pair in header.split(';') { + let trimmed = pair.trim(); + if trimmed.starts_with(needle.as_str()) { + return Some(trimmed[needle.len()..].to_string()); + } + } + None +} + +struct JwtClaims { + sub: Option, + iss: Option, + aud: Option, +} + +fn verify_jwt(token: &str) -> Result { + let dot1 = token.find('.').ok_or("malformed token")?; + let after_header = &token[dot1 + 1..]; + let dot2 = after_header.find('.').ok_or("malformed token")?; + let header_b64 = &token[..dot1]; + let payload_b64 = &after_header[..dot2]; + let sig_b64 = &after_header[dot2 + 1..]; + let message = &token[..dot1 + 1 + dot2]; + + let header_bytes = + URL_SAFE_NO_PAD.decode(header_b64).map_err(|_| "bad header encoding")?; + let header: serde_json::Value = + serde_json::from_slice(&header_bytes).map_err(|_| "bad header JSON")?; + let alg = header["alg"].as_str().unwrap_or_default(); + + // Alg is pinned to HS256 at compile time — token cannot select its own + // verification path. + if alg != EXPECTED_ALG { + println!( + "otp_guard: token alg '{alg}' rejected — filter accepts only {EXPECTED_ALG}" + ); + return Err("unexpected alg"); + } + + let sig_bytes = + URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| "bad signature encoding")?; + + let key_bytes = secret::get("MFA_SESSION_KEY") + .ok() + .flatten() + .unwrap_or_default(); + let key = String::from_utf8(key_bytes).unwrap_or_default(); + if key.is_empty() { + println!("otp_guard: MFA_SESSION_KEY not configured"); + return Err("missing session key"); + } + verify_hs256(message, &sig_bytes, &key)?; + + let payload_bytes = + URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| "bad payload encoding")?; + let payload: serde_json::Value = + serde_json::from_slice(&payload_bytes).map_err(|_| "bad payload JSON")?; + + let exp = payload["exp"] + .as_u64() + .or_else(|| payload["exp"].as_f64().map(|f| f as u64)) + .ok_or("missing exp claim")?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if exp <= now { + return Err("token expired"); + } + + // Honour nbf when present — a token must not be accepted before it becomes + // valid. Absent nbf is fine (the claim is optional). + if let Some(nbf) = payload["nbf"] + .as_u64() + .or_else(|| payload["nbf"].as_f64().map(|f| f as u64)) + { + if now < nbf { + return Err("token not yet valid"); + } + } + + Ok(JwtClaims { + sub: payload["sub"].as_str().map(str::to_owned), + iss: payload["iss"].as_str().map(str::to_owned), + aud: payload.get("aud").cloned(), + }) +} + +fn verify_hs256(message: &str, sig_bytes: &[u8], secret: &str) -> Result<(), &'static str> { + let mut mac = + Hmac::::new_from_slice(secret.as_bytes()).map_err(|_| "bad key length")?; + mac.update(message.as_bytes()); + mac.verify_slice(sig_bytes).map_err(|_| "invalid signature") +} + +fn aud_matches(aud: &Option, expected: &str) -> bool { + match aud { + None => false, + Some(serde_json::Value::String(s)) => s == expected, + Some(serde_json::Value::Array(arr)) => { + arr.iter().any(|v| v.as_str() == Some(expected)) + } + _ => false, + } +} diff --git a/edge-totp/otp-filter/tests/filter.test.ts b/edge-totp/otp-filter/tests/filter.test.ts new file mode 100644 index 0000000..879beaf --- /dev/null +++ b/edge-totp/otp-filter/tests/filter.test.ts @@ -0,0 +1,256 @@ +/** + * Black-box acceptance suite for the Rust enforcement filter (otp-filter). + * + * It drives the *compiled wasm* through + * @gcoredev/fastedge-test rather than unit-testing Rust functions, so it + * exercises the artifact that actually ships — including the request lifecycle, + * env/secret wiring, the deny()/redirect path, and the 401 fail-closed path + * that pure-function tests can't reach. + * + * The filter is HS256-only (the edge-internal mfa_session). Tokens are minted + * with jose to match what otp-app's signMfaSession produces. + */ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SignJWT } from "jose"; +import { + defineTestSuite, + runTestSuite, + runFlow, +} from "@gcoredev/fastedge-test/test"; + +const WASM = "target/wasm32-wasip1/release/totp_filter.wasm"; + +const SESSION_KEY = "test-mfa-session-secret-abcdefghijklmnop0123456789"; +const AUDIENCE = "https://app.example.com"; +const OTHER_AUDIENCE = "https://other.example.com"; +const ISSUER = "https://totp.example.com"; +const LOGIN_URL = "https://cdn.example.com/auth/totp/challenge"; +const DEFAULT_COOKIE = "mfa_session"; +// A protected resource (not under the auth prefix), with a query string so we +// can assert the redirect target is the *relative path* only. +const PROTECTED_URL = "https://cdn.example.com/private/resource?foo=bar"; + +function bytes(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +async function mint( + ttlSeconds: number, + claims: Record = {}, + sub = "user-123", +): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub, amr: ["otp"], ...claims }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds) + .sign(bytes(SESSION_KEY)); +} + +/** + * Hand-craft a token whose header advertises a non-HS256 alg (alg-confusion / + * "alg:none" probe). jose refuses to *mint* these, but an attacker would just + * assemble the bytes — which is what we do here. The signature segment is + * arbitrary; the filter must reject on the header alg before ever checking it. + */ +function craftWithAlg(alg: string): string { + const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url"); + const header = b64({ alg, typ: "JWT" }); + const payload = b64({ sub: "user-123", aud: AUDIENCE, exp: Math.floor(Date.now() / 1000) + 3600 }); + return `${header}.${payload}.`; +} + +function tamper(jwt: string): string { + const [h, p, sig] = jwt.split("."); + const mid = Math.floor(sig.length / 2); + const flipped = sig.slice(0, mid) + (sig[mid] === "A" ? "B" : "A") + sig.slice(mid + 1); + return [h, p, flipped].join("."); +} + +function headerValue(v: string | string[] | undefined): string { + return Array.isArray(v) ? (v[0] ?? "") : (v ?? ""); +} + +interface SuiteResult { + passed: number; + total: number; + failed: number; + durationMs: number; + results: { name: string; passed: boolean; error?: string; durationMs: number }[]; +} + +function report(label: string, result: SuiteResult): void { + console.log(`\n${label}`); + for (const r of result.results) { + const mark = r.passed ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"; + console.log(` ${mark} ${r.name} (${r.durationMs.toFixed(1)}ms)`); + if (!r.passed && r.error) console.log(` ${r.error}`); + } + console.log(` ${result.passed}/${result.total} passed in ${result.durationMs.toFixed(1)}ms`); +} + +// Assertion helpers: the filter signals authorize via Action::Continue +// (returnCode 0) and deny via a synthesized response (302 redirect, or 401 when +// no MFA_LOGIN_URL is configured). +function assertContinue(r: Awaited>): void { + if (r.hookResults.onRequestHeaders.returnCode !== 0) { + throw new Error(`expected Continue (returnCode=0), got ${r.hookResults.onRequestHeaders.returnCode}`); + } + if (r.finalResponse.status === 302 || r.finalResponse.status === 401) { + throw new Error(`expected no deny, got ${r.finalResponse.status}`); + } +} +function assertStatus(r: Awaited>, status: number): void { + if (r.finalResponse.status !== status) { + throw new Error(`expected ${status}, got ${r.finalResponse.status}`); + } +} + +async function envDir(prefix: string, lines: string[]): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + await writeFile(join(dir, ".env"), [...lines, ""].join("\n")); + return dir; +} + +const SECRET_KEY_ENV = `FASTEDGE_VAR_SECRET_MFA_SESSION_KEY=${SESSION_KEY}`; +const cookie = (jwt: string) => ({ cookie: `${DEFAULT_COOKIE}=${jwt}` }); + +const tempDirs: string[] = []; +let totalFailed = 0; + +async function suite( + label: string, + env: string[], + tests: { name: string; run: (runner: Parameters[0]["tests"][number]["run"]>[0]) => Promise }[], +): Promise { + const dir = await envDir("otp-filter-", env); + tempDirs.push(dir); + const result = (await runTestSuite( + defineTestSuite({ + wasmPath: WASM, + runnerConfig: { dotenv: { enabled: true, path: dir } }, + tests, + }), + )) as SuiteResult; + report(label, result); + totalFailed += result.failed; +} + +// Pre-mint tokens. +const validJwt = await mint(3600, { aud: AUDIENCE }); +const expiredJwt = await mint(-3600, { aud: AUDIENCE }); +const tamperedJwt = tamper(validJwt); +const wrongKeyJwt = await new SignJWT({ sub: "user-123", aud: AUDIENCE }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(Math.floor(Date.now() / 1000)) + .setExpirationTime(Math.floor(Date.now() / 1000) + 3600) + .sign(bytes("a-different-secret-key-that-should-not-verify")); +const nowSec = Math.floor(Date.now() / 1000); +const notYetValidJwt = await mint(7200, { aud: AUDIENCE, nbf: nowSec + 3600 }); + +// --- Gate behaviour (audience configured, redirect login configured) -------- +await suite( + "Gate — default config (MFA_AUDIENCE + MFA_LOGIN_URL set):", + [SECRET_KEY_ENV, `FASTEDGE_VAR_ENV_MFA_AUDIENCE=${AUDIENCE}`, `FASTEDGE_VAR_ENV_MFA_LOGIN_URL=${LOGIN_URL}`], + [ + { + name: "valid mfa_session → Continue", + run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(validJwt) })), + }, + { + name: "no cookie → 302 to MFA_LOGIN_URL with relative-path redirect", + run: async (runner) => { + const r = await runFlow(runner, { url: PROTECTED_URL, requestHeaders: {} }); + assertStatus(r, 302); + const loc = headerValue(r.finalResponse.headers["location"]); + if (!loc.startsWith(`${LOGIN_URL}?redirect=`)) { + throw new Error(`expected redirect to login, got '${loc}'`); + } + const target = decodeURIComponent(loc.split("redirect=")[1] ?? ""); + // must be the relative path only — never an absolute URL with host + if (!target.startsWith("/private/resource")) { + throw new Error(`expected relative path target, got '${target}'`); + } + if (target.includes("https://") || target.includes("cdn.example.com")) { + throw new Error(`redirect target leaked absolute URL/host: '${target}'`); + } + }, + }, + { name: "expired token → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(expiredJwt) }), 302) }, + { name: "tampered signature → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(tamperedJwt) }), 302) }, + { name: "wrong signing key → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(wrongKeyJwt) }), 302) }, + { name: "not-yet-valid (nbf in future) → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(notYetValidJwt) }), 302) }, + { name: "malformed token (no dots) → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: { cookie: `${DEFAULT_COOKIE}=not-a-jwt` } }), 302) }, + { + name: "alg=none token → 302 (alg pinned to HS256)", + run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(craftWithAlg("none")) }), 302), + }, + { + name: "alg=RS256 token → 302 (alg pinned, no alg-confusion)", + run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(craftWithAlg("RS256")) }), 302), + }, + // bypass paths must pass through even with no cookie + { name: "/health bypassed → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: "https://cdn.example.com/health", requestHeaders: {} })) }, + { name: "/auth/totp/challenge bypassed → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: "https://cdn.example.com/auth/totp/challenge?t=abc", requestHeaders: {} })) }, + { + name: "traversal /auth/totp/../admin is GATED despite prefix match → 302", + run: async (runner) => assertStatus(await runFlow(runner, { url: "https://cdn.example.com/auth/totp/../admin", requestHeaders: {} }), 302), + }, + ], +); + +// --- 401 fail-closed when no MFA_LOGIN_URL is configured --------------------- +await suite( + "Fail-closed — no MFA_LOGIN_URL (deny must be a hard 401, not a redirect):", + [SECRET_KEY_ENV, `FASTEDGE_VAR_ENV_MFA_AUDIENCE=${AUDIENCE}`], + [ + { name: "no cookie → 401 MFA required", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: {} }), 401) }, + { name: "valid token still → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(validJwt) })) }, + ], +); + +// --- Fail-closed audience: MFA_AUDIENCE unset refuses every session ---------- +await suite( + "Fail-closed audience — MFA_AUDIENCE unset refuses even a valid token:", + [SECRET_KEY_ENV, `FASTEDGE_VAR_ENV_MFA_LOGIN_URL=${LOGIN_URL}`], + [ + { name: "valid token but MFA_AUDIENCE unset → 302 (refuse)", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(validJwt) }), 302) }, + ], +); + +// --- aud / iss enforcement --------------------------------------------------- +await suite( + "aud/iss enforcement (MFA_AUDIENCE + MFA_ISSUER set):", + [SECRET_KEY_ENV, `FASTEDGE_VAR_ENV_MFA_AUDIENCE=${AUDIENCE}`, `FASTEDGE_VAR_ENV_MFA_ISSUER=${ISSUER}`, `FASTEDGE_VAR_ENV_MFA_LOGIN_URL=${LOGIN_URL}`], + [ + { name: "matching aud + iss → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { aud: AUDIENCE, iss: ISSUER })) })) }, + { name: "aud mismatch → 302 (cross-resource replay blocked)", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { aud: OTHER_AUDIENCE, iss: ISSUER })) }), 302) }, + { name: "iss mismatch → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { aud: AUDIENCE, iss: "https://evil.example.com" })) }), 302) }, + { name: "aud array containing configured value → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { aud: [OTHER_AUDIENCE, AUDIENCE], iss: ISSUER })) })) }, + { name: "no aud claim while MFA_AUDIENCE required → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { iss: ISSUER })) }), 302) }, + ], +); + +// --- Custom AUTH_PREFIX + custom cookie name -------------------------------- +await suite( + "Custom AUTH_PREFIX (/mfa) + custom MFA_SESSION_COOKIE:", + [ + SECRET_KEY_ENV, + `FASTEDGE_VAR_ENV_MFA_AUDIENCE=${AUDIENCE}`, + `FASTEDGE_VAR_ENV_MFA_LOGIN_URL=${LOGIN_URL}`, + `FASTEDGE_VAR_ENV_AUTH_PREFIX=/mfa`, + `FASTEDGE_VAR_ENV_MFA_SESSION_COOKIE=my_mfa`, + ], + [ + { name: "custom /mfa/ path bypassed (Continue) with no cookie", run: async (runner) => assertContinue(await runFlow(runner, { url: "https://cdn.example.com/mfa/verify", requestHeaders: {} })) }, + { name: "old default /auth/totp/ NOT bypassed when AUTH_PREFIX overridden → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: "https://cdn.example.com/auth/totp/challenge", requestHeaders: {} }), 302) }, + { name: "valid token in custom cookie name → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: { cookie: `my_mfa=${validJwt}` } })) }, + { name: "token in default cookie name NOT honoured when overridden → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(validJwt) }), 302) }, + ], +); + +for (const dir of tempDirs) await rm(dir, { recursive: true, force: true }).catch(() => {}); +console.log(""); +process.exit(totalFailed > 0 ? 1 : 0); diff --git a/edge-totp/package.json b/edge-totp/package.json new file mode 100644 index 0000000..8b2f407 --- /dev/null +++ b/edge-totp/package.json @@ -0,0 +1,16 @@ +{ + "name": "totp", + "private": true, + "scripts": { + "build": "npm-run-all -s build:app build:filter copy:wasm", + "build:app": "pnpm --filter otp-app build", + "build:filter": "pnpm --filter otp-filter build", + "test": "npm-run-all -p test:app test:filter", + "test:app": "pnpm --filter otp-app test", + "test:filter": "pnpm build:filter && pnpm --filter otp-filter test", + "copy:wasm": "mkdir -p wasm && mv otp-app/dist/totp-app.wasm wasm/totp-app.wasm && cp otp-filter/target/wasm32-wasip1/release/totp_filter.wasm wasm/totp-filter.wasm" + }, + "devDependencies": { + "npm-run-all2": "^9.0.2" + } +} diff --git a/edge-totp/pnpm-lock.yaml b/edge-totp/pnpm-lock.yaml new file mode 100644 index 0000000..a3a2f45 --- /dev/null +++ b/edge-totp/pnpm-lock.yaml @@ -0,0 +1,2783 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.5.1 + version: 2.5.1 + '@gcoredev/fastedge-test': + specifier: ^0.2.4 + version: 0.2.4 + jose: + specifier: ^6.2.2 + version: 6.2.3 + tsx: + specifier: ^4.20.6 + version: 4.22.4 + +importers: + + .: + devDependencies: + npm-run-all2: + specifier: ^9.0.2 + version: 9.0.2 + + otp-app: + dependencies: + hono: + specifier: ^4.9.8 + version: 4.12.27 + jose: + specifier: 'catalog:' + version: 6.2.3 + uqr: + specifier: ^0.1.3 + version: 0.1.3 + devDependencies: + '@gcoredev/fastedge-sdk-js': + specifier: 'catalog:' + version: 2.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@gcoredev/fastedge-test': + specifier: 'catalog:' + version: 0.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + tsx: + specifier: 'catalog:' + version: 4.22.4 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + + otp-filter: + devDependencies: + '@gcoredev/fastedge-test': + specifier: 'catalog:' + version: 0.2.4 + jose: + specifier: 'catalog:' + version: 6.2.3 + tsx: + specifier: 'catalog:' + version: 4.22.4 + +packages: + + '@assemblyscript/wasi-shim@0.1.0': + resolution: {integrity: sha512-fSLH7MdJHf2uDW5llA5VCF/CG62Jp2WkYGui9/3vIWs3jDhViGeQF7nMYLUjpigluM5fnq61I6obtCETy39FZw==} + + '@bytecodealliance/componentize-js@0.19.3': + resolution: {integrity: sha512-ju7Y4WeF0B9uMkSPHJgmT6ouEfSwbe9M1uR/YOnYZjBpxJjH9qzxIkJg/kf8NycVDyFJ2/lscmJ1E1uPiDQVRQ==} + hasBin: true + + '@bytecodealliance/componentize-js@0.21.0': + resolution: {integrity: sha512-Av/XS3bJXE812P/bff3qtrJmehpgD0niK2A0fF3YIdM+jOD6E+mFrxOmuAgaIR0GNkpnm03+GE3ttt/+muqyCQ==} + hasBin: true + + '@bytecodealliance/jco-transpile@0.3.3': + resolution: {integrity: sha512-twkIb3oz8hGEjAnLQNBmQYCHI4YEkaMsSQ0k+iWFYfFefPcm4kFSkQUrjNe0DwMl00KM5kAvG/0r15AYHWs6aw==} + + '@bytecodealliance/jco@1.24.3': + resolution: {integrity: sha512-2NGY31zFdnMqusmmBMrQ9Rlxw89P/BHyQ5BAcrzCMqRRoc95SzuuCtOy8DwWgZ3WeXlWpG9ozAIEW4YPwspgQw==} + hasBin: true + + '@bytecodealliance/preview2-shim@0.17.9': + resolution: {integrity: sha512-i0R3eQBe6PA/o/1EFE3Owe4In2rcccb6QxnjpntM/lPe3/duJ0bRQTVZM2Ufpo99X4eofGeltQUkape1C91FFA==} + + '@bytecodealliance/preview3-shim@0.1.2': + resolution: {integrity: sha512-5twfO5h6zVkJ2QLxrLR21qyLS3jY35XvY0f+jZV4e9FkI6qa2RdPfNDgC50HYhGCtWmbH4TlN1puIyHfEemSNg==} + + '@bytecodealliance/weval@0.4.1': + resolution: {integrity: sha512-vJegSAkNjENhJcMUod76KUGAgQLdACDDCwB3JwyR14zDhyHVPAvArvtDDYEEi+c+ELzls62H6wxTvzRmaYTaqg==} + engines: {node: '>=16'} + + '@bytecodealliance/wizer-darwin-arm64@10.0.0': + resolution: {integrity: sha512-dhZTWel+xccGTKSJtI9A7oM4yyP20FWflsT+AoqkOqkCY7kCNrj4tmMtZ6GXZFRDkrPY5+EnOh62sfShEibAMA==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-darwin-arm64@11.0.3': + resolution: {integrity: sha512-QKU5sLLYMyz+XON5yIqPoffQb8qLUH/qWfnXuVz9GfM9K3sHSnDenkPUmLbqz6L+q+zvXnjhGv+u0wr+DVdR8A==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-darwin-x64@10.0.0': + resolution: {integrity: sha512-r/LUIZw6Q3Hf4htd46mD+EBxfwjBkxVIrTM1r+B2pTCddoBYQnKVdVsI4UFyy7NoBxzEg8F8BwmTNoSLmFRjpw==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-darwin-x64@11.0.3': + resolution: {integrity: sha512-3pAcAtBkpTHDTJL39G9xJMZjBsJrCBS1vo5JXV5URlz8purksv72lsYm9zp4vwFzjwESQPve7ZoH4sZo9QFKGg==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-linux-arm64@10.0.0': + resolution: {integrity: sha512-pGSfFWXzeTqHm6z1PtVaEn+7Fm3QGC8YnHrzBV4sQDVS3N1NwmuHZAc8kslmlFPNdu61ycEvdOsSgCny8JPQvg==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-arm64@11.0.3': + resolution: {integrity: sha512-NHO9cceJ7F4b7sNyHT/GRby+fpm85gk57c7fOernZlRSgpq6/AUae1b48P0UrJXwjob7M3oDnCW/uRu6a6r1Vw==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-s390x@10.0.0': + resolution: {integrity: sha512-O8vHxRTAdb1lUnVXMIMTcp/9q4pq1D4iIKigJCipg2JN15taV9uFAWh0fO88wylXwuSlO7dOE1AwQl54fMKXQg==} + cpu: [s390x] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-s390x@11.0.3': + resolution: {integrity: sha512-G7VScGT8K/H2jvTaQzXTRdfe82jikDxCVR1j4j4jCekmZF0jzV390WauZF/QaDB0FXZY0aAOob7gt76HGbS1aw==} + cpu: [s390x] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-x64@10.0.0': + resolution: {integrity: sha512-fJtM1sy43FBMnp+xpapFX6U1YdTBKA/1T4CYfG/qeE8jn0SXk2EuiYoY/EnC2uyNy9hjTrvfdYO5n4MXW0EIdQ==} + cpu: [x64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-x64@11.0.3': + resolution: {integrity: sha512-HvqiIcnSdaNeQ/D/Q613OXgyW4bWt3bXrl62kv+MvcCeiMskYLclRjvvDH8Mvrjvtjtx0GwuyhyP4gYFaiIQxg==} + cpu: [x64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-win32-x64@10.0.0': + resolution: {integrity: sha512-55BPLfGT7iT7gH5M69NpTM16QknJZ7OxJ0z73VOEoeGA9CT8QPKMRzFKsPIvLs+W8G28fdudFA94nElrdkp3Kg==} + cpu: [x64] + os: [win32] + hasBin: true + + '@bytecodealliance/wizer-win32-x64@11.0.3': + resolution: {integrity: sha512-5MMQwTfpEAHhQabFT7YZtD+XLB9qp1lSliwDqFCiLJkMkxus+6hHIJaMQOiFb6Lb2rP2MA6w57p7T7SYZl9ucg==} + cpu: [x64] + os: [win32] + hasBin: true + + '@bytecodealliance/wizer@10.0.0': + resolution: {integrity: sha512-ziWmovyu1jQl9TsKlfC2bwuUZwxVPFHlX4fOqTzxhgS76jITIo45nzODEwPgU+jjmOr8F3YX2V2wAChC5NKujg==} + engines: {node: '>=16'} + hasBin: true + + '@bytecodealliance/wizer@11.0.3': + resolution: {integrity: sha512-OVKpXne02S5O0TXgfhf5RishlU4YrjA6N76vGGNZWsZZJMAsjy3T35scRVPqtC/k9SDDgzKFGhvXhwma6V8uKg==} + engines: {node: '>=16'} + hasBin: true + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@gcoredev/fastedge-sdk-js@2.5.1': + resolution: {integrity: sha512-kECzIG6//HmJJCKmMppsOgnJrLJsfH4yClgwL2RlgdKEAE9vm38+mSTNOmZjJ+YuqK9dkgotsYQHZnpaB7/VgA==} + engines: {node: '>=22', pnpm: '>=10'} + hasBin: true + + '@gcoredev/fastedge-test@0.2.4': + resolution: {integrity: sha512-1ABCIfQS0Cm8NjWL0ffPwB2wiOrMpXTuOf/s1ztINw9W0gmYLo/uDG40TYnIY+s7XZCfrrFznGhqumklF9Supg==} + engines: {node: '>=22.12'} + hasBin: true + + '@gcoredev/proxy-wasm-sdk-as@1.2.3': + resolution: {integrity: sha512-aBuyNAXnPzgFFUPWNGcxkmcCBvMvyq32D9oBhc8ksQinj80ASNBiLQzF4UDOwProO7+f6te41OZI+JKsP5thmQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + resolution: {integrity: sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/lzma-android-arm64@1.4.5': + resolution: {integrity: sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/lzma-darwin-arm64@1.4.5': + resolution: {integrity: sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/lzma-darwin-x64@1.4.5': + resolution: {integrity: sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/lzma-freebsd-x64@1.4.5': + resolution: {integrity: sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + resolution: {integrity: sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + resolution: {integrity: sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + resolution: {integrity: sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + resolution: {integrity: sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + resolution: {integrity: sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + resolution: {integrity: sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + resolution: {integrity: sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + resolution: {integrity: sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/lzma-wasm32-wasi@1.4.5': + resolution: {integrity: sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + resolution: {integrity: sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + resolution: {integrity: sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + resolution: {integrity: sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma@1.4.5': + resolution: {integrity: sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-parser/binding-android-arm64@0.76.0': + resolution: {integrity: sha512-1XJW/16CDmF5bHE7LAyPPmEEVnxSadDgdJz+xiLqBrmC4lfAeuAfRw3HlOygcPGr+AJsbD4Z5sFJMkwjbSZlQg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.76.0': + resolution: {integrity: sha512-yoQwSom8xsB+JdGsPUU0xxmxLKiF2kdlrK7I56WtGKZilixuBf/TmOwNYJYLRWkBoW5l2/pDZOhBm2luwmLiLw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.76.0': + resolution: {integrity: sha512-uRIopPLvr3pf2Xj7f5LKyCuqzIU6zOS+zEIR8UDYhcgJyZHnvBkfrYnfcztyIcrGdQehrFUi3uplmI09E7RdiQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.76.0': + resolution: {integrity: sha512-a0EOFvnOd2FqmDSvH6uWLROSlU6KV/JDKbsYDA/zRLyKcG6HCsmFnPsp8iV7/xr9WMbNgyJi6R5IMpePQlUq7Q==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.76.0': + resolution: {integrity: sha512-ikRYDHL3fOdZwfJKmcdqjlLgkeNZ3Ez0qM8wAev5zlHZ+lY/Ig7qG5SCqPlvuTu+nNQ6zrFFaKvvt69EBKXU/g==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.76.0': + resolution: {integrity: sha512-dtRv5J5MRCLR7x39K8ufIIW4svIc7gYFUaI0YFXmmeOBhK/K2t/CkguPnDroKtsmXIPHDRtmJ1JJYzNcgJl6Wg==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.76.0': + resolution: {integrity: sha512-IE4iiiggFH2snagQxHrY5bv6dDpRMMat+vdlMN/ibonA65eOmRLp8VLTXnDiNrcla/itJ1L9qGABHNKU+SnE8g==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.76.0': + resolution: {integrity: sha512-wi9zQPMDHrBuRuT7Iurfidc9qlZh7cKa5vfYzOWNBCaqJdgxmNOFzvYen02wVUxSWGKhpiPHxrPX0jdRyJ8Npg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.76.0': + resolution: {integrity: sha512-0tqqu1pqPee2lLGY8vtYlX1L415fFn89e0a3yp4q5N9f03j1rRs0R31qesTm3bt/UK8HYjECZ+56FCVPs2MEMQ==} + engines: {node: '>=20.0.0'} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.76.0': + resolution: {integrity: sha512-y36Hh1a5TA+oIGtlc8lT7N9vdHXBlhBetQJW0p457KbiVQ7jF7AZkaPWhESkjHWAsTVKD2OjCa9ZqfaqhSI0FQ==} + engines: {node: '>=20.0.0'} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.76.0': + resolution: {integrity: sha512-7/acaG9htovp3gp/J0kHgbItQTuHctl+rbqPPqZ9DRBYTz8iV8kv3QN8t8Or8i/hOmOjfZp9McDoSU1duoR4/A==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.76.0': + resolution: {integrity: sha512-AxFt0reY6Q2rfudABmMTFGR8tFFr58NlH2rRBQgcj+F+iEwgJ+jMwAPhXd2y1I2zaI8GspuahedUYQinqxWqjA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-wasm32-wasi@0.76.0': + resolution: {integrity: sha512-wHdkHdhf6AWBoO8vs5cpoR6zEFY1rB+fXWtq6j/xb9j/lu1evlujRVMkh8IM/M/pOUIrNkna3nzST/mRImiveQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.76.0': + resolution: {integrity: sha512-G7ZlEWcb2hNwCK3qalzqJoyB6HaTigQ/GEa7CU8sAJ/WwMdG/NnPqiC9IqpEAEy1ARSo4XMALfKbKNuqbSs5mg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.76.0': + resolution: {integrity: sha512-0jLzzmnu8/mqNhKBnNS2lFUbPEzRdj5ReiZwHGHpjma0+ullmmwP2AqSEqx3ssHDK9CpcEMdKOK2LsbCfhHKIA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.76.0': + resolution: {integrity: sha512-CH3THIrSViKal8yV/Wh3FK0pFhp40nzW1MUDCik9fNuid2D/7JJXKJnfFOAvMxInGXDlvmgT6ACAzrl47TqzkQ==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/node@26.0.0': + resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + binaryen@130.0.0: + resolution: {integrity: sha512-XDrb+zql0RbFPKgj7MuH9zOc78R3Fa/P/VSGnnpdwYsvNZPWjcMYMdAkKCOQEL2A7yqgjSMTDRFp6gfSDW+/QQ==} + hasBin: true + + bl@1.2.3: + resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} + + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + buffer-alloc-unsafe@1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + + buffer-alloc@1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-fill@1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-tar@4.1.1: + resolution: {integrity: sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==} + engines: {node: '>=4'} + + decompress-tarbz2@4.1.1: + resolution: {integrity: sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==} + engines: {node: '>=4'} + + decompress-targz@4.1.1: + resolution: {integrity: sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==} + engines: {node: '>=4'} + + decompress-unzip@4.0.1: + resolution: {integrity: sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==} + engines: {node: '>=4'} + + decompress@4.2.1: + resolution: {integrity: sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==} + engines: {node: '>=4'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-polyfill@0.0.4: + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + file-type@3.9.0: + resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} + engines: {node: '>=0.10.0'} + + file-type@5.2.0: + resolution: {integrity: sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==} + engines: {node: '>=4'} + + file-type@6.2.0: + resolution: {integrity: sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==} + engines: {node: '>=4'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@2.3.1: + resolution: {integrity: sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==} + engines: {node: '>=0.10.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-natural-number@4.0.1: + resolution: {integrity: sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==} + + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@6.0.0: + resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-dir@1.3.0: + resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==} + engines: {node: '>=4'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + npm-normalize-package-bin@6.0.0: + resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-run-all2@9.0.2: + resolution: {integrity: sha512-+dd4SO2jAlLE06OzmJKzIe6QvvjXezcbmobnh8usR0a8BzQCABTdqTXqVPji0ICOhSQpIIrkGd7IzNl5iDaRSA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0, npm: '>= 10'} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + oxc-parser@0.76.0: + resolution: {integrity: sha512-l98B2e9evuhES7zN99rb1QGhbzx25829TJFaKi2j0ib3/K/G5z1FdGYz6HZkrU3U8jdH7v2FC8mX1j2l9JrOUg==} + engines: {node: '>=20.0.0'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pidtree@1.0.0: + resolution: {integrity: sha512-avfAvjB9Dd0wdj3rjJX//yS+G79OO0KrS5pJHFJENjYGX6N4SMgEDBBI/yFy0lloOYSaC6XQxzpOAMPfSYFV/Q==} + engines: {node: '>=18'} + hasBin: true + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pinkie-promise@2.0.1: + resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} + engines: {node: '>=0.10.0'} + + pinkie@2.0.4: + resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} + engines: {node: '>=0.10.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + read-package-json-fast@6.0.0: + resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + seek-bzip@1.0.6: + resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-dirs@2.1.0: + resolution: {integrity: sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==} + + tar-stream@1.6.2: + resolution: {integrity: sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==} + engines: {node: '>= 0.8.0'} + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbzip2-stream@1.4.3: + resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand-debounce@2.3.1: + resolution: {integrity: sha512-QhCM/eXU8tGKaQk5GRvWMeSivj19hcU00SFvo7fUSq1aJK2vYwYpNUvJl+YuKXneuMGIXHjvoou2irpX4OTu2w==} + peerDependencies: + zustand: '>=4.0.0' + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@assemblyscript/wasi-shim@0.1.0': {} + + '@bytecodealliance/componentize-js@0.19.3': + dependencies: + '@bytecodealliance/jco': 1.24.3 + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + + '@bytecodealliance/componentize-js@0.19.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@bytecodealliance/jco': 1.24.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/componentize-js@0.21.0': + dependencies: + '@bytecodealliance/jco': 1.24.3 + '@bytecodealliance/weval': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/componentize-js@0.21.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@bytecodealliance/jco': 1.24.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/weval': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/jco-transpile@0.3.3': + dependencies: + '@bytecodealliance/preview2-shim': 0.17.9 + binaryen: 130.0.0 + terser: 5.48.0 + + '@bytecodealliance/jco@1.24.3': + dependencies: + '@bytecodealliance/componentize-js': 0.21.0 + '@bytecodealliance/componentize-js-0-19-3': '@bytecodealliance/componentize-js@0.19.3' + '@bytecodealliance/jco-transpile': 0.3.3 + '@bytecodealliance/preview2-shim': 0.17.9 + '@bytecodealliance/preview3-shim': 0.1.2 + binaryen: 130.0.0 + commander: 14.0.3 + mkdirp: 3.0.1 + ora: 8.2.0 + terser: 5.48.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/jco@1.24.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@bytecodealliance/componentize-js': 0.21.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/componentize-js-0-19-3': '@bytecodealliance/componentize-js@0.19.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)' + '@bytecodealliance/jco-transpile': 0.3.3 + '@bytecodealliance/preview2-shim': 0.17.9 + '@bytecodealliance/preview3-shim': 0.1.2 + binaryen: 130.0.0 + commander: 14.0.3 + mkdirp: 3.0.1 + ora: 8.2.0 + terser: 5.48.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/preview2-shim@0.17.9': {} + + '@bytecodealliance/preview3-shim@0.1.2': + dependencies: + '@bytecodealliance/preview2-shim': 0.17.9 + + '@bytecodealliance/weval@0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/lzma': 1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + decompress: 4.2.1 + decompress-tar: 4.1.1 + decompress-unzip: 4.0.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/wizer-darwin-arm64@10.0.0': + optional: true + + '@bytecodealliance/wizer-darwin-arm64@11.0.3': + optional: true + + '@bytecodealliance/wizer-darwin-x64@10.0.0': + optional: true + + '@bytecodealliance/wizer-darwin-x64@11.0.3': + optional: true + + '@bytecodealliance/wizer-linux-arm64@10.0.0': + optional: true + + '@bytecodealliance/wizer-linux-arm64@11.0.3': + optional: true + + '@bytecodealliance/wizer-linux-s390x@10.0.0': + optional: true + + '@bytecodealliance/wizer-linux-s390x@11.0.3': + optional: true + + '@bytecodealliance/wizer-linux-x64@10.0.0': + optional: true + + '@bytecodealliance/wizer-linux-x64@11.0.3': + optional: true + + '@bytecodealliance/wizer-win32-x64@10.0.0': + optional: true + + '@bytecodealliance/wizer-win32-x64@11.0.3': + optional: true + + '@bytecodealliance/wizer@10.0.0': + optionalDependencies: + '@bytecodealliance/wizer-darwin-arm64': 10.0.0 + '@bytecodealliance/wizer-darwin-x64': 10.0.0 + '@bytecodealliance/wizer-linux-arm64': 10.0.0 + '@bytecodealliance/wizer-linux-s390x': 10.0.0 + '@bytecodealliance/wizer-linux-x64': 10.0.0 + '@bytecodealliance/wizer-win32-x64': 10.0.0 + + '@bytecodealliance/wizer@11.0.3': + optionalDependencies: + '@bytecodealliance/wizer-darwin-arm64': 11.0.3 + '@bytecodealliance/wizer-darwin-x64': 11.0.3 + '@bytecodealliance/wizer-linux-arm64': 11.0.3 + '@bytecodealliance/wizer-linux-s390x': 11.0.3 + '@bytecodealliance/wizer-linux-x64': 11.0.3 + '@bytecodealliance/wizer-win32-x64': 11.0.3 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@gcoredev/fastedge-sdk-js@2.5.1': + dependencies: + '@bytecodealliance/jco': 1.24.3 + '@bytecodealliance/wizer': 11.0.3 + acorn: 8.17.0 + acorn-walk: 8.3.5 + arg: 5.0.2 + enquirer: 2.4.1 + esbuild: 0.28.1 + event-target-polyfill: 0.0.4 + magic-string: 0.30.21 + npm-run-all2: 9.0.2 + prompts: 2.4.2 + regexpu-core: 6.4.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@gcoredev/fastedge-sdk-js@2.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@bytecodealliance/jco': 1.24.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/wizer': 11.0.3 + acorn: 8.17.0 + acorn-walk: 8.3.5 + arg: 5.0.2 + enquirer: 2.4.1 + esbuild: 0.28.1 + event-target-polyfill: 0.0.4 + magic-string: 0.30.21 + npm-run-all2: 9.0.2 + prompts: 2.4.2 + regexpu-core: 6.4.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@gcoredev/fastedge-test@0.2.4': + dependencies: + '@assemblyscript/wasi-shim': 0.1.0 + '@gcoredev/fastedge-sdk-js': 2.5.1 + '@gcoredev/proxy-wasm-sdk-as': 1.2.3 + '@types/ws': 8.18.1 + express: 4.22.2 + immer: 11.1.8 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + undici: 6.27.0 + ws: 8.21.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + zustand: 5.0.14(immer@11.1.8)(react@19.2.7) + zustand-debounce: 2.3.1(zustand@5.0.14(immer@11.1.8)(react@19.2.7)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@types/react' + - bufferutil + - supports-color + - use-sync-external-store + - utf-8-validate + + '@gcoredev/fastedge-test@0.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@assemblyscript/wasi-shim': 0.1.0 + '@gcoredev/fastedge-sdk-js': 2.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@gcoredev/proxy-wasm-sdk-as': 1.2.3 + '@types/ws': 8.18.1 + express: 4.22.2 + immer: 11.1.8 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + undici: 6.27.0 + ws: 8.21.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + zustand: 5.0.14(immer@11.1.8)(react@19.2.7) + zustand-debounce: 2.3.1(zustand@5.0.14(immer@11.1.8)(react@19.2.7)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@types/react' + - bufferutil + - supports-color + - use-sync-external-store + - utf-8-validate + + '@gcoredev/proxy-wasm-sdk-as@1.2.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + optional: true + + '@napi-rs/lzma-android-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-x64@1.4.5': + optional: true + + '@napi-rs/lzma-freebsd-x64@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma@1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + optionalDependencies: + '@napi-rs/lzma-android-arm-eabi': 1.4.5 + '@napi-rs/lzma-android-arm64': 1.4.5 + '@napi-rs/lzma-darwin-arm64': 1.4.5 + '@napi-rs/lzma-darwin-x64': 1.4.5 + '@napi-rs/lzma-freebsd-x64': 1.4.5 + '@napi-rs/lzma-linux-arm-gnueabihf': 1.4.5 + '@napi-rs/lzma-linux-arm64-gnu': 1.4.5 + '@napi-rs/lzma-linux-arm64-musl': 1.4.5 + '@napi-rs/lzma-linux-ppc64-gnu': 1.4.5 + '@napi-rs/lzma-linux-riscv64-gnu': 1.4.5 + '@napi-rs/lzma-linux-s390x-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-musl': 1.4.5 + '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/lzma-win32-arm64-msvc': 1.4.5 + '@napi-rs/lzma-win32-ia32-msvc': 1.4.5 + '@napi-rs/lzma-win32-x64-msvc': 1.4.5 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-parser/binding-android-arm64@0.76.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.76.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.76.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.76.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.76.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.76.0': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.76.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.76.0': + optional: true + + '@oxc-project/types@0.76.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@26.0.0': + dependencies: + undici-types: 8.3.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.0.0 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@6.2.3: {} + + arg@5.0.2: {} + + array-flatten@1.1.1: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + base64-js@1.5.1: {} + + binaryen@130.0.0: {} + + bl@1.2.3: + dependencies: + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + buffer-alloc-unsafe@1.1.0: {} + + buffer-alloc@1.2.0: + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + + buffer-crc32@0.2.13: {} + + buffer-fill@1.0.0: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chalk@5.6.2: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + commander@14.0.3: {} + + commander@2.20.3: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + core-util-is@1.0.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + decompress-tar@4.1.1: + dependencies: + file-type: 5.2.0 + is-stream: 1.1.0 + tar-stream: 1.6.2 + + decompress-tarbz2@4.1.1: + dependencies: + decompress-tar: 4.1.1 + file-type: 6.2.0 + is-stream: 1.1.0 + seek-bzip: 1.0.6 + unbzip2-stream: 1.4.3 + + decompress-targz@4.1.1: + dependencies: + decompress-tar: 4.1.1 + file-type: 5.2.0 + is-stream: 1.1.0 + + decompress-unzip@4.0.1: + dependencies: + file-type: 3.9.0 + get-stream: 2.3.1 + pify: 2.3.0 + yauzl: 2.10.0 + + decompress@4.2.1: + dependencies: + decompress-tar: 4.1.1 + decompress-tarbz2: 4.1.1 + decompress-targz: 4.1.1 + decompress-unzip: 4.0.1 + graceful-fs: 4.2.11 + make-dir: 1.3.0 + pify: 2.3.0 + strip-dirs: 2.1.0 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + emoji-regex@10.6.0: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + event-target-polyfill@0.0.4: {} + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + file-type@3.9.0: {} + + file-type@5.2.0: {} + + file-type@6.2.0: {} + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@2.3.1: + dependencies: + object-assign: 4.1.1 + pinkie-promise: 2.0.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.27: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immer@11.1.8: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-callable@1.2.7: {} + + is-interactive@2.0.0: {} + + is-natural-number@4.0.1: {} + + is-stream@1.1.0: {} + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@4.0.0: {} + + jose@6.2.3: {} + + jsesc@3.1.0: {} + + json-parse-even-better-errors@6.0.0: {} + + kleur@3.0.3: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@1.3.0: + dependencies: + pify: 3.0.0 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + memorystream@0.3.1: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-function@5.0.1: {} + + mkdirp@3.0.1: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + npm-normalize-package-bin@6.0.0: {} + + npm-run-all2@9.0.2: + dependencies: + ansi-styles: 6.2.3 + cross-spawn: 7.0.6 + memorystream: 0.3.1 + picomatch: 4.0.4 + pidtree: 1.0.0 + read-package-json-fast: 6.0.0 + shell-quote: 1.8.4 + which: 7.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + oxc-parser@0.76.0: + dependencies: + '@oxc-project/types': 0.76.0 + optionalDependencies: + '@oxc-parser/binding-android-arm64': 0.76.0 + '@oxc-parser/binding-darwin-arm64': 0.76.0 + '@oxc-parser/binding-darwin-x64': 0.76.0 + '@oxc-parser/binding-freebsd-x64': 0.76.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.76.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.76.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.76.0 + '@oxc-parser/binding-linux-arm64-musl': 0.76.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.76.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.76.0 + '@oxc-parser/binding-linux-x64-gnu': 0.76.0 + '@oxc-parser/binding-linux-x64-musl': 0.76.0 + '@oxc-parser/binding-wasm32-wasi': 0.76.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.76.0 + '@oxc-parser/binding-win32-x64-msvc': 0.76.0 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@0.1.13: {} + + pend@1.2.0: {} + + picomatch@4.0.4: {} + + pidtree@1.0.0: {} + + pify@2.3.0: {} + + pify@3.0.0: {} + + pinkie-promise@2.0.1: + dependencies: + pinkie: 2.0.4 + + pinkie@2.0.4: {} + + possible-typed-array-names@1.1.0: {} + + process-nextick-args@2.0.1: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + + read-package-json-fast@6.0.0: + dependencies: + json-parse-even-better-errors: 6.0.0 + npm-normalize-package-bin: 6.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + seek-bzip@1.0.6: + dependencies: + commander: 2.20.3 + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.4: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + statuses@2.0.2: {} + + stdin-discarder@0.2.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-dirs@2.1.0: + dependencies: + is-natural-number: 4.0.1 + + tar-stream@1.6.2: + dependencies: + bl: 1.2.3 + buffer-alloc: 1.2.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + readable-stream: 2.3.8 + to-buffer: 1.2.2 + xtend: 4.0.2 + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + through@2.3.8: {} + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + toidentifier@1.0.1: {} + + tslib@2.8.1: + optional: true + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typescript@5.9.3: {} + + unbzip2-stream@1.4.3: + dependencies: + buffer: 5.7.1 + through: 2.3.8 + + undici-types@8.3.0: {} + + undici@6.27.0: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unpipe@1.0.0: {} + + uqr@0.1.3: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@7.0.0: + dependencies: + isexe: 4.0.0 + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + xtend@4.0.2: {} + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + + zustand-debounce@2.3.1(zustand@5.0.14(immer@11.1.8)(react@19.2.7)): + dependencies: + zustand: 5.0.14(immer@11.1.8)(react@19.2.7) + + zustand@5.0.14(immer@11.1.8)(react@19.2.7): + optionalDependencies: + immer: 11.1.8 + react: 19.2.7 diff --git a/edge-totp/pnpm-workspace.yaml b/edge-totp/pnpm-workspace.yaml new file mode 100644 index 0000000..000c4a0 --- /dev/null +++ b/edge-totp/pnpm-workspace.yaml @@ -0,0 +1,15 @@ +packages: + - otp-app + - otp-filter + +# Single source of truth for shared dependency versions. Packages reference these +# with the "catalog:" specifier instead of pinning their own version, so the +# HTTP app (otp-app) and the filter's test harness (otp-filter) can't drift. +catalog: + "@gcoredev/fastedge-test": ^0.2.4 + "@gcoredev/fastedge-sdk-js": ^2.5.1 + jose: ^6.2.2 + tsx: ^4.20.6 + +onlyBuiltDependencies: + - esbuild From e200b073a71fb7a237a4d69467c726a56d73ec0a Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 30 Jun 2026 17:54:44 +0100 Subject: [PATCH 2/2] copilot --- edge-totp/otp-app/src/index.ts | 11 +++++++++-- edge-totp/otp-filter/src/lib.rs | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/edge-totp/otp-app/src/index.ts b/edge-totp/otp-app/src/index.ts index ef68489..3b4c586 100644 --- a/edge-totp/otp-app/src/index.ts +++ b/edge-totp/otp-app/src/index.ts @@ -148,9 +148,11 @@ function buildApp(authPrefix: string): Hono { const claims = await verifyHandoffTicket(ticket, secrets.handoffKey); if (!claims) return c.text("Invalid or expired ticket", 400); + if (!cfg.kvStoreName) return c.text("KV_STORE_NAME not configured", 503); + // Not enrolled: send to self-service activation, or refuse when self-service // enrollment is disabled (admin-provisioned deployments). - if (cfg.kvStoreName && !isEnrolled(cfg.kvStoreName, claims.sub, cfg.kvKeyPrefix)) { + if (!isEnrolled(cfg.kvStoreName, claims.sub, cfg.kvKeyPrefix)) { if (!cfg.allowSelfEnrollment) { return c.text("Not enrolled. Please contact your administrator.", 403); } @@ -452,7 +454,12 @@ function buildApp(authPrefix: string): Hono { if (!cfg.kvStoreName) return c.text("KV_STORE_NAME not configured", 503); const seed = readSeed(cfg.kvStoreName, userId, cfg.kvKeyPrefix); - if (!seed) return c.json({ error: "User not enrolled" }, 403); + if (!seed) { + if (ct.includes("application/json")) { + return c.json({ error: "User not enrolled" }, 403); + } + return c.text("Not enrolled. Please contact your administrator.", 403); + } // Find which time-step matched, then check POP-local replay guard const matchedStep = await findMatchingStep(code, seed, { diff --git a/edge-totp/otp-filter/src/lib.rs b/edge-totp/otp-filter/src/lib.rs index 79da52f..2164a90 100644 --- a/edge-totp/otp-filter/src/lib.rs +++ b/edge-totp/otp-filter/src/lib.rs @@ -58,7 +58,10 @@ impl HttpContext for OtpGuard { let cookie_header = self.get_http_request_header("cookie").unwrap_or_default(); let token = match extract_cookie(&cookie_header, &cookie_name) { Some(t) => t, - None => return self.deny(&full_path, &login_url, "no mfa_session cookie"), + None => { + let reason = format!("no {cookie_name} cookie"); + return self.deny(&full_path, &login_url, &reason); + } }; let claims = match verify_jwt(&token) { @@ -89,7 +92,7 @@ impl HttpContext for OtpGuard { } println!( - "otp_guard: authorized — valid mfa_session (sub={}) for {full_path}", + "otp_guard: authorized — valid {cookie_name} (sub={}) for {full_path}", claims.sub.as_deref().unwrap_or("") );