diff --git a/.loupe/scanner-config.json b/.loupe/scanner-config.json new file mode 100644 index 0000000..e4fdd5b --- /dev/null +++ b/.loupe/scanner-config.json @@ -0,0 +1,13 @@ +{ + "max_concurrent_files": 1, + "include_extensions": ["js", "mjs", "ts", "tsx"], + "extra_source_paths": [ + "src", + "scripts/create-four-node-devnet.mjs", + "Dockerfile", + "docker-compose.yml", + "docker-compose.portainer.yml", + "docker/caddy/Caddyfile", + "vite.config.ts" + ] +} diff --git a/Dockerfile b/Dockerfile index 17aee4b..8de6fa0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:20-bookworm-slim AS build WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci COPY . . RUN npm run build @@ -15,11 +15,12 @@ ENV NODE_ENV=production ENV PORT=8787 ENV DATA_DIR=/app/data -COPY --from=build /app/package.json ./package.json -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/dist ./dist +COPY --from=build --chown=node:node /app/package.json ./package.json +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/dist ./dist -RUN mkdir -p /app/data +RUN install -d -o node -g node -m 700 /app/data EXPOSE 8787 +USER node CMD ["node", "dist/server/server/index.js"] diff --git a/README.md b/README.md index 68ea73d..fa96cdd 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ The backend is Node/Express. The frontend is React/Vite and follows the dark, co ## Planning Docs - [Genesis release automation plan](docs/genesis-release-automation-plan.md): proposed event history, configurable `go-zenon` release targets, operator-specific bootstrap scripts, and node polling automation. +- [Loupe security scanning](docs/loupe-security-scanning.md): guarded repository registration, scanner scope, and baseline/incremental scan commands. ## Important Security Notes -Set `APP_SECRET` before using the app outside local testing. It is used as the server-side encryption key for sensitive stored wallet package secrets, especially wallet passwords. +Set `APP_SECRET` before using the app outside local testing. It is used as the server-side encryption key for sensitive stored wallet package secrets, especially wallet passwords. Production startup fails when this value is missing, shorter than 32 characters, or still uses the known development default. Do not change `APP_SECRET` after operators register pillars. Existing encrypted wallet package secrets will no longer decrypt correctly if the secret changes. @@ -45,6 +46,10 @@ Treat these as secret material: The generated `data/`, `dist/`, `node_modules/`, and `devnet/four-node/` directories are intentionally ignored by git. +The application container runs as the bundled non-root `node` user, and its named data volume is initialized for that user. Custom bind mounts must grant that user write access without making the state directory broadly readable. Authentication endpoints also apply bounded per-account and per-source delays and temporary lockouts; retain a proxy-level rate limit as an additional deployment control. + +Browser responses include a restrictive security-header baseline and use only local system fonts. HSTS is emitted when `COOKIE_SECURE=true`, which must only be enabled behind confirmed HTTPS termination. Secret-bearing downloads explicitly disable intermediary and browser caching. + ## Requirements - Node.js 20 or newer for local development. @@ -81,15 +86,19 @@ The node bootstrap script reads its active install target from the admin setting ```text GO_ZENON_REPO=https://github.com/zenon-network/go-zenon.git GO_ZENON_REF=master +GO_ZENON_COMMIT= DEPLOYMENT_REPO=https://github.com/hypercore-one/deployment.git DEPLOYMENT_REF=main +DEPLOYMENT_COMMIT= ``` -After the app is running, admins can edit the go-zenon repo/ref, optional commit label, deployment repo, deployment ref, one-shot data wipe flag, and optional release apply time from the Settings panel. Saving these values only updates the draft settings. They do not reach `/node-plan.json` or authenticated bootstrap manifests until an admin clicks **Publish Release**. Set `GO_ZENON_REF` to a branch or tag that the deployment script can clone with `git clone -b`. +After the app is running, admins can edit the go-zenon and deployment repositories, refs, immutable commit pins, one-shot data wipe flag, and optional release apply time from the Settings panel. Saving these values only updates the draft settings. They do not reach `/node-plan.json` or authenticated bootstrap manifests until an admin clicks **Publish Release**. + +Both full commit pins are required before publishing. Each ref is fetched and must resolve to its configured commit exactly; a mismatch aborts before deployment code is executed. This prevents a moved branch or tag from silently changing privileged installation inputs. ## Standalone Docker -Use `docker-compose.yml` when you want the repo to run its own Caddy container. This is the easiest local or single-host setup. +Use `docker-compose.yml` when you want the repo to run its own Caddy container for local access. The bundled HTTP endpoint is bound to host loopback and must not be exposed directly to another network. ```bash APP_SECRET="$(openssl rand -hex 32)" docker compose up -d --build @@ -110,9 +119,35 @@ http://localhost:8080 The standalone stack contains: - `app`: the Node/React application on internal port `8787`. -- `caddy`: a bundled reverse proxy exposed on `${HTTP_PORT:-8080}`. +- `caddy`: a bundled reverse proxy exposed only on `127.0.0.1:${HTTP_PORT:-8080}`. - `testnet-data`: persistent app state mounted at `/app/data`. +For remote access, use the Portainer profile behind its HTTPS proxy or an equivalent deployment that terminates TLS and sets secure cookies. Do not change the standalone binding to `0.0.0.0`; the bundled Caddy profile serves cleartext HTTP. + +The supplied Compose profiles set a canonical `PUBLIC_BASE_URL` and one trusted reverse-proxy hop. Custom production deployments must also set `PUBLIC_BASE_URL` to the exact external HTTPS origin and set `TRUST_PROXY_HOPS` to the exact number of trusted proxy hops. Request `Host` and forwarded-host headers are not used to construct privileged bootstrap URLs when the canonical origin is present. + +## Existing Docker Volume Upgrades + +Fresh named volumes are initialized for the non-root `node` user and require no migration. Images from before the non-root hardening change may have created `/app/data` and its contents as root. Repair that ownership once, before the first upgrade that starts the application as `node`. + +For the standalone Compose profile, stop the application and run the one-time helper with the same Compose project and environment configuration: + +```bash +docker compose stop app +docker compose run --rm --no-deps --user root --entrypoint sh app \ + -c 'chown -R 1000:1000 /app/data && chmod 700 /app/data' +docker compose up -d +``` + +For an existing Portainer deployment, open a console in the currently running pre-hardening `testnet-builder` container and run this before pulling and redeploying the stack: + +```bash +chown -R 1000:1000 /app/data +chmod 700 /app/data +``` + +The migration changes ownership only inside the mounted application state directory. Keep the existing volume and `APP_SECRET`, and do not replace the private mode with a world-writable permission such as `777`. + ## Portainer With Existing Caddy Use `docker-compose.portainer.yml` when an existing Caddy Docker Proxy stack already handles TLS certificates and routing. This stack runs only the app container and attaches it to the external `root_proxy-net` network. @@ -169,11 +204,15 @@ Set these stack environment variables: - `APP_SECRET`: a stable secret, for example the output of `openssl rand -hex 32`. - `TESTNET_HOST`: the public host Caddy should route, for example `testnet.zenon.info`. +- `PUBLIC_BASE_URL`: derived by the supplied stack as `https://`; set it explicitly for custom deployments. +- `TRUST_PROXY_HOPS`: set to `1` by the supplied proxy topology; custom deployments must match their exact trusted proxy chain. - `TZ`: optional, defaults to `Etc/UTC`. - `GO_ZENON_REPO`: optional initial default, defaults to `https://github.com/zenon-network/go-zenon.git`. - `GO_ZENON_REF`: optional initial default, defaults to `master`. +- `GO_ZENON_COMMIT`: optional initial full commit pin; required before publishing. - `DEPLOYMENT_REPO`: optional initial default, defaults to `https://github.com/hypercore-one/deployment.git`. - `DEPLOYMENT_REF`: optional initial default, defaults to `main`. +- `DEPLOYMENT_COMMIT`: optional initial full commit pin; required before publishing. Example values: @@ -245,9 +284,10 @@ Sign in as `admin`, create operator accounts, collect pillar and seed-node regis When new commits are pushed to `main`: -1. Open the stack in Portainer. -2. Pull and redeploy the Git stack. -3. Keep the same persistent volume and the same `APP_SECRET`. +1. If this is the first update from a root-running image, complete the one-time volume ownership migration above. +2. Open the stack in Portainer. +3. Pull and redeploy the Git stack. +4. Keep the same persistent volume and the same `APP_SECRET`. The app stores state in the named volume `zenon_testnet_builder_data` at `/app/data`. @@ -297,14 +337,18 @@ Seed-node packages do not include pillar, reward, or producer wallets. The seed ## Operator Bootstrap -After registering a pillar or seed node, the operator page shows a copyable command shaped like this: +After registering a pillar or seed node, the operator page shows a one-time enrollment token and a copyable command shaped like this: ```bash -curl -fsSL "https:///api/bootstrap/install.sh" | sudo env ZNN_BOOTSTRAP_TOKEN="" ZNN_TESTNET_URL="https://" bash +read -rsp 'Enrollment token: ' ZNN_ENROLLMENT_TOKEN && printf '\n' && \ +printf '%s' "$ZNN_ENROLLMENT_TOKEN" | sudo install -m 600 /dev/stdin /run/znn-testnet-enrollment-token && \ +unset ZNN_ENROLLMENT_TOKEN && \ +curl -fsSL "https:///api/bootstrap/install.sh" | sudo env ZNN_ENROLLMENT_TOKEN_FILE=/run/znn-testnet-enrollment-token ZNN_TESTNET_URL="https://" bash ``` Run it on the node host. The script is intended for the same Linux/systemd style environment supported by `hypercore-one/deployment`. -In **Node Deployment**, the go-zenon repo and branch/tag choose the node source code that gets built. The deployment script repo and branch/tag choose the installer scripts that clone, build, install, and manage the service. The optional go-zenon commit pin is only needed when a release must be tied to an exact commit instead of the branch tip. +Paste the enrollment token only at the hidden prompt. It is written through standard input to a root-owned mode-`0600` file and is not embedded in shell history, environment values, cron content, or command arguments. +In **Node Deployment**, the go-zenon and deployment refs select the source revisions to fetch. Both refs must resolve exactly to their required full commit pins. The agent aborts before executing deployment code when either commit does not match. For testnet operators, the bootstrap agent relaxes the deployment script CPU pre-flight minimum from 4 cores to 2 cores by default. Override it by adding `ZNN_DEPLOYMENT_MIN_CPU_CORES=""` to the bootstrap command if a stricter minimum is needed. The agent also changes the deployment script's total RAM check from a hard failure to a warning. A 4 GB VPS can report as `3GiB` after integer rounding, so the script will log the RAM finding and keep going. 4 GiB remains the recommended minimum for builds. The initial bootstrap run and the one-minute cron job share `/var/lock/znn-testnet-agent.lock`, so a long go-zenon build cannot be started twice. If `zenon.sh` reports `Failed to build binary`, check `/opt/zenon-deployment/.znnsh.log` for the underlying Go compiler error. @@ -320,25 +364,26 @@ The bootstrap flow before a release is published: After **Publish Release**, the agent waits until `actions.applyAt` if that timestamp is present and in the future. When the apply time has arrived, the agent: -1. Downloads the authenticated bootstrap manifest with the node token. -2. Clones `DEPLOYMENT_REPO` at `DEPLOYMENT_REF`. -3. Patches the deployment pre-flight CPU minimum to `ZNN_DEPLOYMENT_MIN_CPU_CORES`, default `2`, and changes the total RAM check to warning-only. -4. Runs `./zenon.sh --deploy zenon "$GO_ZENON_REPO" "$GO_ZENON_REF"` to build and install go-zenon. -5. Stops `go-zenon`. -6. Wipes node data if the published node plan has `actions.wipeData: true`. -7. Writes `/root/.znn/genesis.json`. -8. Writes the node-specific `/root/.znn/config.json`. -9. For pillars, writes `/root/.znn/wallet/producer.json` and `/root/.znn/wallet/producer-password.txt`. -10. For managed seed nodes, writes `/root/.znn/network-private-key`. -11. Restarts `go-zenon` and sends a status report. +1. Reads the authenticated manifest with the enrollment token. +2. At the release apply time, exchanges enrollment once for a node-status token and a 30-minute secret-download token. +3. Downloads missing producer or network secrets immediately with the short-lived token. +4. Fetches `DEPLOYMENT_REF` and verifies that it resolves exactly to `DEPLOYMENT_COMMIT`. +5. Fetches `GO_ZENON_REF`, verifies `GO_ZENON_COMMIT`, and exposes only that verified local source to the pinned deployment script. +6. Patches the deployment pre-flight CPU minimum to `ZNN_DEPLOYMENT_MIN_CPU_CORES`, default `2`, and changes the total RAM check to warning-only. +7. Builds and installs go-zenon from the verified local revision. +8. Stops `go-zenon`. +9. Wipes node data if the published node plan has `actions.wipeData: true`. +10. Writes `/root/.znn/genesis.json` and the node-specific `/root/.znn/config.json`. +11. Restores the local producer password into pillar config without sending it through the long-lived status credential. +12. Restarts `go-zenon`, revokes the short-lived server capability, deletes the local secret-download token, and sends a status report. The wipe action is controlled by **Wipe node data on next Publish Release** in admin Settings. It is one-shot: publishing a release snapshots the flag into `/node-plan.json`, then clears the draft checkbox. **Apply Release At (UTC)** is also one-shot: publishing snapshots it into `/node-plan.json`, then clears the draft field. The agent preserves `/root/.znn/wallet`, `/root/.znn/genesis.json`, `/root/.znn/config.json`, and `/root/.znn/network-private-key`, and removes other files/directories under `/root/.znn` before writing the published artifacts. -The token in the bootstrap command also authorizes node-specific downloads and node status reporting. Treat it like an operator secret. +Enrollment tokens expire after seven days and can be used once. Rotating enrollment credentials invalidates the previous node-status credential. The issued status token can read manifests and non-secret node configuration and submit status, but it cannot download producer key files, producer passwords, or seed-node network private keys. ## Node Status Reporting -Each registered pillar or managed seed node receives a private node status token in its operator package and bootstrap command. The installed agent uses that token to report health back to the orchestrator without exposing the app username or password. +Each registered pillar or managed seed node receives a private node status token in its operator package. The bootstrap agent receives the current status token only through the one-time enrollment exchange and stores it in a root-owned credential file. It uses that token to report health without exposing the app username or password. Heartbeat reports are sent with a bearer token: @@ -412,7 +457,7 @@ For managed seed nodes, create an operator user, then use the admin **Seed Nodes This managed flow does not query the seed node RPC and does not require the seed node to be running before genesis/config are published. The enode and libp2p multiaddr are deterministic from the generated network private key plus public IP/port. -For an external already-running seed node, use the **External Seeder / RPC Probe** panel before publishing the config. The app calls the seed node RPC on port `35997` by default, reads `stats.networkInfo.self.publicKey`, and saves both an `enode://@:35995` entry into `Net.Seeders` and a `/ip4//tcp/35995/p2p/` entry into `Net.BootstrapPeers`. +For an external already-running seed node, use the **External Seeder / RPC Probe** panel before publishing the config. The app calls the seed node RPC on port `35997` by default, reads `stats.networkInfo.self.publicKey`, and saves both an `enode://@:35995` entry into `Net.Seeders` and a `/ip4//tcp/35995/p2p/` entry into `Net.BootstrapPeers`. The probe accepts publicly routable literal IP addresses only, rejects local and special-use ranges, and does not follow redirects. Use the node's public IP address. If RPC or p2p is exposed on non-default ports, adjust the ports in the external seeder probe form before probing. @@ -507,6 +552,9 @@ Node heartbeat reporting: Operator bootstrap: - `GET /api/bootstrap/install.sh` +- `POST /api/bootstrap/enroll` +- `POST /api/bootstrap/complete` +- `POST /api/bootstrap/enrollment-token` - `GET /api/bootstrap/manifest` - `GET /api/bootstrap/node-config.json` - `GET /api/bootstrap/pillar-config.json` diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml index 2823325..71bfc7b 100644 --- a/docker-compose.portainer.yml +++ b/docker-compose.portainer.yml @@ -8,13 +8,17 @@ services: NODE_ENV: production TZ: ${TZ:-Etc/UTC} PORT: 8787 + TRUST_PROXY_HOPS: 1 DATA_DIR: /app/data APP_SECRET: ${APP_SECRET:?Set APP_SECRET in the Portainer stack environment} COOKIE_SECURE: "true" + PUBLIC_BASE_URL: https://${TESTNET_HOST:-testnet.zenon.info} GO_ZENON_REPO: ${GO_ZENON_REPO:-https://github.com/zenon-network/go-zenon.git} GO_ZENON_REF: ${GO_ZENON_REF:-master} + GO_ZENON_COMMIT: ${GO_ZENON_COMMIT:-} DEPLOYMENT_REPO: ${DEPLOYMENT_REPO:-https://github.com/hypercore-one/deployment.git} DEPLOYMENT_REF: ${DEPLOYMENT_REF:-main} + DEPLOYMENT_COMMIT: ${DEPLOYMENT_COMMIT:-} volumes: - zenon_testnet_builder_data:/app/data expose: diff --git a/docker-compose.yml b/docker-compose.yml index eb40dc8..206afba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,10 +6,14 @@ services: APP_SECRET: ${APP_SECRET:?Set APP_SECRET before starting the stack} DATA_DIR: /app/data PORT: 8787 + TRUST_PROXY_HOPS: 1 + PUBLIC_BASE_URL: http://127.0.0.1:${HTTP_PORT:-8080} GO_ZENON_REPO: ${GO_ZENON_REPO:-https://github.com/zenon-network/go-zenon.git} GO_ZENON_REF: ${GO_ZENON_REF:-master} + GO_ZENON_COMMIT: ${GO_ZENON_COMMIT:-} DEPLOYMENT_REPO: ${DEPLOYMENT_REPO:-https://github.com/hypercore-one/deployment.git} DEPLOYMENT_REF: ${DEPLOYMENT_REF:-main} + DEPLOYMENT_COMMIT: ${DEPLOYMENT_COMMIT:-} volumes: - testnet-data:/app/data @@ -19,7 +23,7 @@ services: depends_on: - app ports: - - "${HTTP_PORT:-8080}:80" + - "127.0.0.1:${HTTP_PORT:-8080}:80" volumes: - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data diff --git a/docs/loupe-security-scanning.md b/docs/loupe-security-scanning.md new file mode 100644 index 0000000..fe51c1d --- /dev/null +++ b/docs/loupe-security-scanning.md @@ -0,0 +1,75 @@ +# Loupe Security Scanning + +This repository carries a Project Loupe scanner profile at +`.loupe/scanner-config.json`. The profile covers the TypeScript application +and the operational files that automatic package-root discovery would not +normally include, especially the devnet wallet-generation script, container +definitions, Caddy configuration, and Vite configuration. + +The profile relies on per-repository scanner configuration, including +`extra_source_paths`. Use the compatible Loupe fork at +`https://github.com/edgepillar/loupe.git`, pinned to commit +`5c8744c1b2823415fe851d17bae92ff8f7193a15`. Do not silently substitute a +Loupe revision that does not implement these scanner fields. + +## Register the repository + +Run these commands from a checkout of this repository after the Loupe server, +worker, and `loupectl` client are configured: + +```bash +loupectl repo add \ + --clone-url https://github.com/0x3639/testnet.git \ + --branch main \ + --scanner-config-file .loupe/scanner-config.json \ + --no-reporting \ + --verification-enabled \ + --require-approval +``` + +The safe initial policy is deliberate: + +- `max_concurrent_files` remains `1` to bound provider usage and keep the pilot + deliberately serial. Increase it only after reviewing measured usage and + scan behavior. +- `--no-reporting` keeps findings in Loupe for manual triage until a tracker + repository and scoped GitHub token are selected. +- `--verification-enabled` asks a second agent to validate each candidate. +- `--require-approval` prevents a confirmed finding from being dispatched + without an operator decision. +- No scan interval is set, so the first runs are explicitly controlled and + their provider usage can be observed. + +The command prints the assigned repository ID. Start the baseline scan with: + +```bash +loupectl repo scan +``` + +Inspect progress and results with: + +```bash +loupectl job list +loupectl finding list +loupectl finding show +``` + +After the baseline completes, scan later changes incrementally: + +```bash +loupectl repo scan --incremental +``` + +## Profile lifecycle + +Loupe stores the scanner JSON when the repository is registered. If +`.loupe/scanner-config.json` changes, reload it without discarding prior jobs +or findings: + +```bash +loupectl repo update \ + --scanner-config-file .loupe/scanner-config.json +``` + +Do not put Loupe credentials, API keys, GitHub tokens, wallet material, or +testnet operator secrets in this profile. diff --git a/package-lock.json b/package-lock.json index 9614705..a9e0ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "zenon-testnet-builder", "version": "0.1.0", "dependencies": { - "@vitejs/plugin-react": "^4.3.4", "cookie-parser": "^1.4.7", "express": "^4.21.2", "jszip": "^3.10.1", @@ -24,6 +23,7 @@ "@types/node": "^22.10.2", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.3" @@ -36,6 +36,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -50,6 +51,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -59,6 +61,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -89,6 +92,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -105,6 +109,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.29.7", @@ -121,6 +126,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -130,6 +136,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -143,6 +150,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -160,6 +168,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -169,6 +178,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -178,6 +188,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -187,6 +198,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -196,6 +208,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", @@ -209,6 +222,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -224,6 +238,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -239,6 +254,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -254,6 +270,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -268,6 +285,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -286,6 +304,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -302,6 +321,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -318,6 +338,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -334,6 +355,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -350,6 +372,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -366,6 +389,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -382,6 +406,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -398,6 +423,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -414,6 +440,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -430,6 +457,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -446,6 +474,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -462,6 +491,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -478,6 +508,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -494,6 +525,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -510,6 +542,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -526,6 +559,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -542,6 +576,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -558,6 +593,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -574,6 +610,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -590,6 +627,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -606,6 +644,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -622,6 +661,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -638,6 +678,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -654,6 +695,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -670,6 +712,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -686,6 +729,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -702,6 +746,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -715,6 +760,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -725,6 +771,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -735,6 +782,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -744,12 +792,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -801,6 +851,7 @@ "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -810,6 +861,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -823,6 +875,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -836,6 +889,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -849,6 +903,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -862,6 +917,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -875,6 +931,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -888,6 +945,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -901,6 +959,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -914,6 +973,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -927,6 +987,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -940,6 +1001,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -953,6 +1015,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -966,6 +1029,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -979,6 +1043,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -992,6 +1057,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1005,6 +1071,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1018,6 +1085,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1031,6 +1099,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1044,6 +1113,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1057,6 +1127,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1070,6 +1141,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1083,6 +1155,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1096,6 +1169,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1109,6 +1183,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1122,6 +1197,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1163,6 +1239,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -1176,6 +1253,7 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -1185,6 +1263,7 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -1195,6 +1274,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -1235,6 +1315,7 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, "license": "MIT" }, "node_modules/@types/express": { @@ -1360,6 +1441,7 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.28.0", @@ -1472,6 +1554,7 @@ "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1493,9 +1576,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1612,6 +1695,7 @@ "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1745,6 +1829,7 @@ "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1809,6 +1894,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -1925,6 +2011,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2035,6 +2122,7 @@ "version": "1.5.376", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "dev": true, "license": "ISC" }, "node_modules/elliptic": { @@ -2101,7 +2189,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2143,6 +2231,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2244,6 +2333,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2351,6 +2441,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2374,6 +2465,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2638,6 +2730,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -2650,6 +2743,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -2695,6 +2789,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -2827,9 +2922,10 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", - "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, "funding": [ { "type": "github", @@ -2877,6 +2973,7 @@ "version": "2.0.48", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2964,12 +3061,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2988,9 +3087,10 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3007,7 +3107,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3141,6 +3241,7 @@ "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3199,6 +3300,7 @@ "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.9" @@ -3301,6 +3403,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3485,6 +3588,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3524,6 +3628,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3575,7 +3680,7 @@ "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -3656,6 +3761,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3737,6 +3843,7 @@ "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -3814,6 +3921,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3830,6 +3938,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3846,6 +3955,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3862,6 +3972,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3878,6 +3989,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3894,6 +4006,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3910,6 +4023,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3926,6 +4040,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3942,6 +4057,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3958,6 +4074,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3974,6 +4091,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3990,6 +4108,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4006,6 +4125,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4022,6 +4142,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4038,6 +4159,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4054,6 +4176,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4070,6 +4193,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4086,6 +4210,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4102,6 +4227,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4118,6 +4244,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4134,6 +4261,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4150,6 +4278,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4166,6 +4295,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4182,6 +4312,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4198,6 +4329,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4214,6 +4346,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4227,6 +4360,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4286,9 +4420,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4310,6 +4444,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, "license": "ISC" }, "node_modules/znn-typescript-sdk": { diff --git a/package.json b/package.json index f888c18..f28e007 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,11 @@ "build:web": "vite build", "start": "node dist/server/server/index.js", "account": "tsx src/server/cli.ts", - "typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web.json --noEmit" + "typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web.json --noEmit", + "test": "npm run test:security", + "test:security": "npm run build:api && node --test tests/security-hardening.test.mjs" }, "dependencies": { - "@vitejs/plugin-react": "^4.3.4", "cookie-parser": "^1.4.7", "express": "^4.21.2", "jszip": "^3.10.1", @@ -34,8 +35,15 @@ "@types/node": "^22.10.2", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.3" + }, + "overrides": { + "body-parser": "1.20.6", + "nanoid": "3.3.18", + "postcss": "8.5.26", + "ws": "8.21.3" } } diff --git a/scripts/create-four-node-devnet.mjs b/scripts/create-four-node-devnet.mjs index 7751af2..6228cd8 100644 --- a/scripts/create-four-node-devnet.mjs +++ b/scripts/create-four-node-devnet.mjs @@ -1,5 +1,5 @@ import { createECDH, randomBytes } from "node:crypto"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import JSZip from "jszip"; @@ -16,6 +16,8 @@ const ZNN_ZTS = "zts1znnxxxxxxxxxxxxx9z4ulx"; const QSR_ZTS = "zts1qsrxxxxxxxxxxxxxmrhjll"; const PILLAR_CONTRACT = "z1qxemdeddedxpyllarxxxxxxxxxxxxxxxsy3fmg"; const PLASMA_CONTRACT = "z1qxemdeddedxplasmaxxxxxxxxxxxxxxxxsctrp"; +const PRIVATE_DIRECTORY_MODE = 0o700; +const PRIVATE_FILE_MODE = 0o600; const seedNode = { role: "seed", ip: "10.88.0.9", httpPort: 36000, wsPort: 36100 }; @@ -26,6 +28,16 @@ const roles = [ { role: "pillar4", pillarName: "dev4", username: "devnet-node-4", ip: "10.88.0.13", httpPort: 36004, wsPort: 36104 } ]; +async function ensurePrivateDir(dirPath) { + await mkdir(dirPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + await chmod(dirPath, PRIVATE_DIRECTORY_MODE); +} + +async function writePrivateFile(filePath, data) { + await writeFile(filePath, data, { mode: PRIVATE_FILE_MODE }); + await chmod(filePath, PRIVATE_FILE_MODE); +} + function randomPassword() { return randomBytes(18).toString("base64url"); } @@ -151,9 +163,9 @@ function nodeConfigFromPackage(config, role) { HTTPPort: 35997, WSHost: "0.0.0.0", WSPort: 35998, - HTTPVirtualHosts: ["*"], - HTTPCors: ["*"], - WSOrigins: ["*"], + HTTPVirtualHosts: ["localhost", "127.0.0.1"], + HTTPCors: [], + WSOrigins: [], Endpoints: ["ledger", "stats", "embedded", "subscribe"] }, Net: { @@ -186,9 +198,9 @@ function seedNodeConfig() { HTTPPort: 35997, WSHost: "0.0.0.0", WSPort: 35998, - HTTPVirtualHosts: ["*"], - HTTPCors: ["*"], - WSOrigins: ["*"], + HTTPVirtualHosts: ["localhost", "127.0.0.1"], + HTTPCors: [], + WSOrigins: [], Endpoints: ["ledger", "stats", "embedded", "subscribe"] }, Net: { @@ -318,6 +330,13 @@ async function main() { expectedPillars: 4, minPillars: 3, genesisTimestampSec: Math.floor(Date.now() / 1000), + goZenonRepo: overview.settings.goZenonRepo, + goZenonRef: overview.settings.goZenonRef, + goZenonCommit: overview.settings.goZenonCommit, + deploymentRepo: overview.settings.deploymentRepo, + deploymentRef: overview.settings.deploymentRef, + deploymentCommit: overview.settings.deploymentCommit, + wipeDataOnPublish: false, seeders: [seedNode.enode], bootstrapPeers: [seedNode.multiaddr], sporks: overview.settings.sporks @@ -329,19 +348,19 @@ async function main() { overview = (await request("/api/admin/overview", {}, adminCookie)).body; await rm(OUT_DIR, { recursive: true, force: true }); - await mkdir(DEVNET_DIR, { recursive: true }); - await mkdir(OPERATORS_DIR, { recursive: true }); - await writeFile(path.join(DEVNET_DIR, "genesis.json"), pretty(overview.genesis)); + await ensurePrivateDir(DEVNET_DIR); + await ensurePrivateDir(OPERATORS_DIR); + await writePrivateFile(path.join(DEVNET_DIR, "genesis.json"), pretty(overview.genesis)); const configs = { seed: seedNodeConfig() }; const seedDir = path.join(DEVNET_DIR, seedNode.role); - await mkdir(seedDir, { recursive: true }); - await writeFile(path.join(seedDir, "config.json"), pretty(configs.seed)); - await writeFile(path.join(seedDir, "network-private-key"), seedNode.nodeKey.privateKey); + await ensurePrivateDir(seedDir); + await writePrivateFile(path.join(seedDir, "config.json"), pretty(configs.seed)); + await writePrivateFile(path.join(seedDir, "network-private-key"), seedNode.nodeKey.privateKey); for (const role of roles) { const packageResponse = await request("/api/pillar/package", {}, role.userCookie); - await writeFile(path.join(OPERATORS_DIR, `${role.pillarName}-pillar-package.zip`), packageResponse.body); + await writePrivateFile(path.join(OPERATORS_DIR, `${role.pillarName}-pillar-package.zip`), packageResponse.body); const zip = await JSZip.loadAsync(packageResponse.body); const packageConfig = JSON.parse(await zip.file("config.json").async("string")); @@ -350,21 +369,22 @@ async function main() { configs[role.role] = config; const roleDir = path.join(DEVNET_DIR, role.role); - await mkdir(path.join(roleDir, "wallet"), { recursive: true }); - await writeFile(path.join(roleDir, "config.json"), pretty(config)); - await writeFile(path.join(roleDir, "network-private-key"), role.nodeKey.privateKey); - await writeFile(path.join(roleDir, "wallet", "producer.json"), pretty(producerWallet)); + await ensurePrivateDir(roleDir); + await ensurePrivateDir(path.join(roleDir, "wallet")); + await writePrivateFile(path.join(roleDir, "config.json"), pretty(config)); + await writePrivateFile(path.join(roleDir, "network-private-key"), role.nodeKey.privateKey); + await writePrivateFile(path.join(roleDir, "wallet", "producer.json"), pretty(producerWallet)); } const genesisChecks = validateGenesis(overview.genesis, roles.map((role) => role.pillar)); const configChecks = validateConfigs(configs); const failedChecks = [...genesisChecks, ...configChecks].filter((check) => !check.ok); - await writeFile( + await writePrivateFile( path.join(OUT_DIR, "Dockerfile"), `FROM go-zenon-devnet:latest\nRUN rm -rf /devnet\nCOPY devnet /devnet\n` ); - await writeFile( + await writePrivateFile( path.join(OUT_DIR, "docker-compose.yml"), `name: zenon-generated-devnet @@ -380,8 +400,8 @@ ${roles environment: ZNND_ROLE: ${role.role} ports: - - "${role.httpPort}:35997" - - "${role.wsPort}:35998" + - "127.0.0.1:${role.httpPort}:35997" + - "127.0.0.1:${role.wsPort}:35998" volumes: - ${role.role}-data:/root/.znn networks: @@ -401,11 +421,11 @@ volumes: ${roles.concat(seedNode).map((role) => ` ${role.role}-data:`).join("\n")} ` ); - await writeFile( + await writePrivateFile( path.join(OUT_DIR, "operator-logins.txt"), roles.map((role) => `${role.username}\t${role.password}\t${BASE_URL}`).join("\n") + "\n" ); - await writeFile( + await writePrivateFile( path.join(OUT_DIR, "summary.json"), pretty({ builderUrl: BASE_URL, @@ -433,7 +453,7 @@ ${roles.concat(seedNode).map((role) => ` ${role.role}-data:`).join("\n")} } }) ); - await writeFile( + await writePrivateFile( path.join(OUT_DIR, "README.md"), `# Four Node Zenon Devnet diff --git a/src/server/accounts.ts b/src/server/accounts.ts index c0641b1..648d3e0 100644 --- a/src/server/accounts.ts +++ b/src/server/accounts.ts @@ -1,4 +1,4 @@ -import { hashPassword, randomId, randomPassword } from "./crypto.js"; +import { hashPassword, randomId, randomPassword, sha256 } from "./crypto.js"; import { updateState } from "./storage.js"; import type { AuthUser, Role } from "../shared/types.js"; @@ -29,16 +29,17 @@ export async function createAccount(username: string, role: Role, password = ran return { user, password }; } -export async function resetAccountPassword(userId: string, password: string, keepActiveSessionUserId?: string): Promise { +export async function resetAccountPassword(userId: string, password: string, keepActiveSessionToken?: string): Promise { const passwordHash = await hashPassword(password); + const keepActiveSessionTokenHash = keepActiveSessionToken ? sha256(keepActiveSessionToken) : undefined; return updateState((state) => { const user = state.users.find((candidate) => candidate.id === userId); if (!user) throw new Error("User not found"); user.passwordHash = passwordHash; - if (user.id !== keepActiveSessionUserId) { - state.sessions = state.sessions.filter((session) => session.userId !== user.id); - } + state.sessions = state.sessions.filter( + (session) => session.userId !== user.id || (keepActiveSessionTokenHash !== undefined && session.tokenHash === keepActiveSessionTokenHash) + ); return { id: user.id, diff --git a/src/server/auth.ts b/src/server/auth.ts index a8cef6f..384be77 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -5,6 +5,8 @@ import type { AuthUser, Role, StoredSession, StoredUser } from "../shared/types. const SESSION_COOKIE = "zenon_session"; const SESSION_DAYS = 7; +const INVALID_LOGIN_PASSWORD_HASH = + "scrypt:00000000000000000000000000000000:0dd104d85ff10031cc0d8e988708bfc1ff90223756a71e057dca5d131b07e2739b284775e4396025ae788702b5d0a3c6eb3af57985fe7550ae7bddca86862fa0"; export interface AuthedRequest extends Request { user: AuthUser; @@ -21,10 +23,8 @@ export function publicUser(user: StoredUser): AuthUser { export async function login(username: string, password: string): Promise<{ token: string; user: AuthUser } | null> { const state = await readState(); const user = state.users.find((candidate) => candidate.username.toLowerCase() === username.toLowerCase()); - if (!user) return null; - - const ok = await verifyPassword(password, user.passwordHash); - if (!ok) return null; + const ok = await verifyPassword(password, user?.passwordHash ?? INVALID_LOGIN_PASSWORD_HASH); + if (!user || !ok) return null; const token = randomId(32); const now = new Date(); diff --git a/src/server/credentials.ts b/src/server/credentials.ts new file mode 100644 index 0000000..48524bb --- /dev/null +++ b/src/server/credentials.ts @@ -0,0 +1,112 @@ +import { decryptText, encryptText, randomId, sha256 } from "./crypto.js"; +import type { PillarRecord, SeedNodeRecord } from "../shared/types.js"; + +const ENROLLMENT_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const SECRET_DOWNLOAD_TOKEN_TTL_MS = 30 * 60 * 1000; + +export type NodeCredentialRecord = PillarRecord | SeedNodeRecord; + +export function createStatusTokenFields(): { statusTokenHash: string; statusTokenCipher: string } { + const token = randomId(32); + return { + statusTokenHash: sha256(token), + statusTokenCipher: encryptText(token) + }; +} + +export function createEnrollmentTokenFields(now = new Date()) { + const token = randomId(32); + return { + enrollmentTokenHash: sha256(token), + enrollmentTokenCipher: encryptText(token), + enrollmentTokenExpiresAt: new Date(now.getTime() + ENROLLMENT_TOKEN_TTL_MS).toISOString(), + enrollmentTokenUsedAt: undefined + }; +} + +export function createNodeCredentialFields(now = new Date()) { + return { + ...createStatusTokenFields(), + ...createEnrollmentTokenFields(now) + }; +} + +export function ensureNodeCredentialFields(record: NodeCredentialRecord): void { + if (!record.statusTokenHash || !record.statusTokenCipher) { + Object.assign(record, createStatusTokenFields()); + } + if (!record.enrollmentTokenUsedAt && (!record.enrollmentTokenHash || !record.enrollmentTokenCipher || !record.enrollmentTokenExpiresAt)) { + Object.assign(record, createEnrollmentTokenFields()); + } +} + +export function statusToken(record: Pick): string { + return record.statusTokenCipher ? decryptText(record.statusTokenCipher) : ""; +} + +export function activeEnrollment(record: NodeCredentialRecord, now = new Date()): { token: string; expiresAt: string } | undefined { + if ( + !record.enrollmentTokenHash || + !record.enrollmentTokenCipher || + !record.enrollmentTokenExpiresAt || + record.enrollmentTokenUsedAt || + new Date(record.enrollmentTokenExpiresAt) <= now + ) { + return undefined; + } + return { + token: decryptText(record.enrollmentTokenCipher), + expiresAt: record.enrollmentTokenExpiresAt + }; +} + +export function activeEnrollmentMatches(record: NodeCredentialRecord, tokenHash: string, now = new Date()): boolean { + return Boolean( + record.enrollmentTokenHash === tokenHash && + !record.enrollmentTokenUsedAt && + record.enrollmentTokenExpiresAt && + new Date(record.enrollmentTokenExpiresAt) > now + ); +} + +export function secretDownloadMatches(record: NodeCredentialRecord, tokenHash: string, now = new Date()): boolean { + return Boolean( + record.secretDownloadTokenHash === tokenHash && + record.secretDownloadTokenExpiresAt && + new Date(record.secretDownloadTokenExpiresAt) > now + ); +} + +export function rotateNodeCredentials(record: NodeCredentialRecord): { token: string; expiresAt: string } { + Object.assign(record, createNodeCredentialFields()); + clearSecretDownloadToken(record); + const enrollment = activeEnrollment(record); + if (!enrollment) throw new Error("Failed to rotate node enrollment credentials"); + return enrollment; +} + +export function consumeEnrollment(record: NodeCredentialRecord, token: string, now = new Date()) { + if (!activeEnrollmentMatches(record, sha256(token), now)) { + throw new Error("Invalid, expired, or already used enrollment token"); + } + + ensureNodeCredentialFields(record); + const secretToken = randomId(32); + const secretTokenExpiresAt = new Date(now.getTime() + SECRET_DOWNLOAD_TOKEN_TTL_MS).toISOString(); + record.enrollmentTokenUsedAt = now.toISOString(); + record.enrollmentTokenHash = undefined; + record.enrollmentTokenCipher = undefined; + record.secretDownloadTokenHash = sha256(secretToken); + record.secretDownloadTokenExpiresAt = secretTokenExpiresAt; + + return { + statusToken: statusToken(record), + secretToken, + secretTokenExpiresAt + }; +} + +export function clearSecretDownloadToken(record: NodeCredentialRecord): void { + record.secretDownloadTokenHash = undefined; + record.secretDownloadTokenExpiresAt = undefined; +} diff --git a/src/server/crypto.ts b/src/server/crypto.ts index ea4680e..5188c7e 100644 --- a/src/server/crypto.ts +++ b/src/server/crypto.ts @@ -2,12 +2,21 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt as sc import { promisify } from "node:util"; const scrypt = promisify(scryptCallback); -const SECRET = process.env.APP_SECRET ?? "dev-secret-change-me"; +const DEVELOPMENT_SECRET = "dev-secret-change-me"; -if (!process.env.APP_SECRET && process.env.NODE_ENV === "production") { - console.warn("APP_SECRET is not set; using the development secret. Set APP_SECRET before using this outside local testing."); +function applicationSecret(): string { + const configuredSecret = process.env.APP_SECRET?.trim(); + if ( + process.env.NODE_ENV === "production" && + (!configuredSecret || configuredSecret === DEVELOPMENT_SECRET || configuredSecret.length < 32) + ) { + throw new Error("APP_SECRET must be set to a non-default value in production and contain at least 32 characters."); + } + return configuredSecret || DEVELOPMENT_SECRET; } +const SECRET = applicationSecret(); + function keyFromSecret(): Buffer { return createHash("sha256").update(SECRET).digest(); } diff --git a/src/server/genesis.ts b/src/server/genesis.ts index 87ad203..124c991 100644 --- a/src/server/genesis.ts +++ b/src/server/genesis.ts @@ -12,6 +12,7 @@ import { ZNN_ZTS } from "./constants.js"; import { stableHashHex } from "./crypto.js"; +import { isPinnedGitCommit } from "./releases.js"; import type { AppState, NetworkSettings, PillarRecord, PublicPillar, ReadinessCheck } from "../shared/types.js"; function units(amount: number): number { @@ -188,18 +189,19 @@ export function buildNodeConfig( Address: pillar.producerWallet.address, Index: pillar.producerIndex, KeyFilePath: paths.producerKeyFilePath ?? `${walletPath}/producer.json`, - Password: producerPassword ?? "" + ...(producerPassword === undefined ? {} : { Password: producerPassword }) } : undefined, RPC: { EnableHTTP: true, EnableWS: true, - HTTPHost: "0.0.0.0", + HTTPHost: "127.0.0.1", HTTPPort: 35997, - WSHost: "0.0.0.0", + WSHost: "127.0.0.1", WSPort: 35998, - HTTPCors: ["*"], - WSOrigins: ["*"], + HTTPVirtualHosts: ["localhost", "127.0.0.1"], + HTTPCors: [], + WSOrigins: [], Endpoints: ["ledger", "stats", "embedded", "subscribe"] }, Net: { @@ -249,6 +251,16 @@ export function readiness(state: AppState): ReadinessCheck[] { detail: (state.settings.bootstrapPeers ?? []).length ? `${(state.settings.bootstrapPeers ?? []).length} configured` : "Required for libp2p after activation" + }, + { + label: "go-zenon commit pin", + ok: isPinnedGitCommit(state.settings.goZenonCommit), + detail: isPinnedGitCommit(state.settings.goZenonCommit) ? state.settings.goZenonCommit : "Full commit required before publish" + }, + { + label: "Deployment commit pin", + ok: isPinnedGitCommit(state.settings.deploymentCommit), + detail: isPinnedGitCommit(state.settings.deploymentCommit) ? state.settings.deploymentCommit : "Full commit required before publish" } ]; } diff --git a/src/server/index.ts b/src/server/index.ts index c827d31..8658edc 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,14 +2,30 @@ import cookieParser from "cookie-parser"; import express from "express"; import { createECDH } from "node:crypto"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { z } from "zod"; import { clearSessionCookie, login, logout, requireAuth, sessionTokenFromRequest, setSessionCookie, type AuthedRequest } from "./auth.js"; import { createAccount, resetAccountPassword } from "./accounts.js"; +import { + activeEnrollment, + activeEnrollmentMatches, + clearSecretDownloadToken, + consumeEnrollment, + createNodeCredentialFields, + ensureNodeCredentialFields, + rotateNodeCredentials, + secretDownloadMatches, + statusToken, + type NodeCredentialRecord +} from "./credentials.js"; import { decryptText, encryptText, randomId, sha256 } from "./crypto.js"; import { buildGenesis, buildNodeConfig, readiness, toPublicPillar } from "./genesis.js"; import { buildPillarPackage, buildSeedNodePackage, buildSporkPackage } from "./packages.js"; import { enodeFromPublicKey, multiaddrFromEnode, multiaddrFromPublicKey } from "./libp2p.js"; -import { probeSeedNode, validateSeedNodeIp } from "./seeders.js"; +import { probeSeedNode, validateSeedNodeIp, validateSeedProbeIp } from "./seeders.js"; +import { isPinnedGitCommit, requireReleasePins } from "./releases.js"; +import { configuredPublicOrigin } from "./origin.js"; +import { LoginAttemptLimiter } from "./login-rate-limit.js"; import { readState, updateState } from "./storage.js"; import { createWallet, toStoredWallet } from "./wallets.js"; import type { @@ -30,10 +46,28 @@ const PUBLIC_GENESIS_PATH = "/genesis.json"; const PUBLIC_CONFIG_PATH = "/config.json"; const PUBLIC_NODE_PLAN_PATH = "/node-plan.json"; const NODE_STATUS_HISTORY_LIMIT = 24 * 60; +const PUBLIC_ORIGIN = configuredPublicOrigin(); +const TRUST_PROXY_HOPS = Number(process.env.TRUST_PROXY_HOPS ?? 0); +const LOGIN_WINDOW_MS = 15 * 60 * 1000; +const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; +const accountLoginAttempts = new LoginAttemptLimiter({ + maxAttempts: 5, + windowMs: LOGIN_WINDOW_MS, + lockoutMs: LOGIN_LOCKOUT_MS +}); +const sourceLoginAttempts = new LoginAttemptLimiter({ + maxAttempts: 20, + windowMs: LOGIN_WINDOW_MS, + lockoutMs: LOGIN_LOCKOUT_MS +}); + +if (!Number.isInteger(TRUST_PROXY_HOPS) || TRUST_PROXY_HOPS < 0 || TRUST_PROXY_HOPS > 10) { + throw new Error("TRUST_PROXY_HOPS must be an integer between 0 and 10."); +} const loginSchema = z.object({ - username: z.string().min(1), - password: z.string().min(1) + username: z.string().trim().min(1).max(40), + password: z.string().min(1).max(200) }); const nodeNameSchema = z @@ -75,6 +109,18 @@ const accountPasswordSchema = z.object({ password: passwordSchema }); +const optionalGitCommitSchema = z + .string() + .trim() + .max(64) + .refine((value) => value === "" || isPinnedGitCommit(value), "Use a full 40- or 64-character hexadecimal commit") + .optional(); +const githubRepositorySchema = z + .string() + .trim() + .max(300) + .regex(/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/i, "Use an HTTPS GitHub repository URL"); + const settingsSchema = z.object({ chainIdentifier: z.number().int().positive(), extraData: z.string().min(1).max(240), @@ -82,11 +128,12 @@ const settingsSchema = z.object({ minPillars: z.number().int().min(1).max(100), genesisTimestampSec: z.number().int().positive(), releaseApplyAtSec: z.number().int().positive().optional(), - goZenonRepo: z.string().trim().min(1).max(300), + goZenonRepo: githubRepositorySchema, goZenonRef: z.string().trim().min(1).max(160), - goZenonCommit: z.string().trim().max(80).optional(), - deploymentRepo: z.string().trim().min(1).max(300), + goZenonCommit: optionalGitCommitSchema, + deploymentRepo: githubRepositorySchema, deploymentRef: z.string().trim().min(1).max(160), + deploymentCommit: optionalGitCommitSchema, wipeDataOnPublish: z.boolean().default(false), seeders: z.array(z.string().trim().min(1)).max(100), bootstrapPeers: z.array(z.string().trim().min(1)).max(100).optional(), @@ -102,7 +149,7 @@ const settingsSchema = z.object({ }); const seedNodeProbeSchema = z.object({ - ip: z.string().trim().refine(validateSeedNodeIp, "Seed node must be an IP address"), + ip: z.string().trim().refine(validateSeedProbeIp, "Seed probe target must be a publicly routable IP address"), rpcPort: z.number().int().min(1).max(65535).default(35997), p2pPort: z.number().int().min(1).max(65535).default(35995) }); @@ -249,6 +296,10 @@ function managedUsers(state: AppState): ManagedUser[] { .sort((a, b) => a.username.localeCompare(b.username)); } +function nodeRecordForUser(state: AppState, userId: string): NodeCredentialRecord | undefined { + return state.pillars.find((candidate) => candidate.userId === userId) ?? state.seedNodes.find((candidate) => candidate.userId === userId); +} + async function ensureSporkWallet(): Promise { await updateState(async (state) => { if (state.settings.sporkWallet && state.settings.sporkAddress) return; @@ -258,26 +309,13 @@ async function ensureSporkWallet(): Promise { }); } -function createStatusTokenFields(): { statusTokenHash: string; statusTokenCipher: string } { - const token = randomId(32); - return { - statusTokenHash: sha256(token), - statusTokenCipher: encryptText(token) - }; -} - -function ensureStatusToken(record: { statusTokenHash?: string; statusTokenCipher?: string }): void { - if (record.statusTokenHash && record.statusTokenCipher) return; - Object.assign(record, createStatusTokenFields()); -} - -async function ensurePillarStatusTokens(): Promise { +async function ensureNodeCredentials(): Promise { await updateState((state) => { for (const pillar of state.pillars) { - ensureStatusToken(pillar); + ensureNodeCredentialFields(pillar); } for (const seedNode of state.seedNodes) { - ensureStatusToken(seedNode); + ensureNodeCredentialFields(seedNode); } }); } @@ -306,7 +344,7 @@ async function createPillar(userId: string, pillarName: string) { rewardWallet: toStoredWallet(rewardWallet), producerWallet: toStoredWallet(producerWallet), producerIndex: 0, - ...createStatusTokenFields(), + ...createNodeCredentialFields(), createdAt: new Date().toISOString() }; state.pillars.push(record); @@ -351,7 +389,7 @@ async function createSeedNode(userId: string, nodeName: string, publicIp: string enode, multiaddr, networkPrivateKeyCipher: encryptText(privateKey), - ...createStatusTokenFields(), + ...createNodeCredentialFields(), createdAt: new Date().toISOString() }; state.seedNodes.push(record); @@ -364,9 +402,36 @@ async function createSeedNode(userId: string, nodeName: string, publicIp: string function sendDownload(response: express.Response, filename: string, contentType: string, body: Buffer | string): void { response.setHeader("Content-Type", contentType); response.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + response.setHeader("Cache-Control", "no-store, max-age=0"); + response.setHeader("Pragma", "no-cache"); + response.setHeader("X-Content-Type-Options", "nosniff"); response.send(body); } +function loginRetryAfterMs(request: express.Request, normalizedUsername?: string): number { + const sourceKey = request.ip || request.socket.remoteAddress || "unknown"; + const sourceRetry = sourceLoginAttempts.retryAfterMs(sourceKey); + const accountRetry = normalizedUsername ? accountLoginAttempts.retryAfterMs(normalizedUsername) : 0; + return Math.max(sourceRetry, accountRetry); +} + +function recordLoginAttempt(request: express.Request, normalizedUsername?: string): void { + const sourceKey = request.ip || request.socket.remoteAddress || "unknown"; + sourceLoginAttempts.recordAttempt(sourceKey); + if (normalizedUsername) accountLoginAttempts.recordAttempt(normalizedUsername); +} + +function clearLoginAttempts(request: express.Request, normalizedUsername: string): void { + const sourceKey = request.ip || request.socket.remoteAddress || "unknown"; + sourceLoginAttempts.success(sourceKey); + accountLoginAttempts.success(normalizedUsername); +} + +function sendLoginRateLimit(response: express.Response, retryAfterMs: number): void { + response.setHeader("Retry-After", String(Math.max(1, Math.ceil(retryAfterMs / 1000)))); + response.status(429).json({ error: "Too many login attempts. Try again later." }); +} + function prettyJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } @@ -421,6 +486,7 @@ function genesisSettingsKey(settings: NetworkSettings): string { } function requestOrigin(request: express.Request): string { + if (PUBLIC_ORIGIN) return PUBLIC_ORIGIN; const forwardedProto = request.get("x-forwarded-proto")?.split(",")[0]?.trim(); const forwardedHost = request.get("x-forwarded-host")?.split(",")[0]?.trim(); const proto = forwardedProto || request.protocol; @@ -428,10 +494,6 @@ function requestOrigin(request: express.Request): string { return `${proto}://${host}`; } -function statusToken(record: { statusTokenCipher?: string }): string { - return record.statusTokenCipher ? decryptText(record.statusTokenCipher) : ""; -} - function producerPassword(pillar: PillarRecord): string { return decryptText(pillar.producerWallet.passwordCipher); } @@ -444,7 +506,7 @@ function bearerToken(request: express.Request): string | undefined { } function pillarConfigForDeployment(settings: NetworkSettings, pillar: PillarRecord): unknown { - return buildNodeConfig(settings, pillar, producerPassword(pillar), { + return buildNodeConfig(settings, pillar, undefined, { dataPath: "/root/.znn", walletPath: "/root/.znn/wallet", genesisFile: "/root/.znn/genesis.json", @@ -479,7 +541,8 @@ function releaseTarget(settings: NetworkSettings | NetworkSettingsSnapshot) { }, deployment: { repoUrl: settings.deploymentRepo, - ref: settings.deploymentRef + ref: settings.deploymentRef, + commit: settings.deploymentCommit || undefined } }; } @@ -550,6 +613,7 @@ async function withBootstrapNode( response: express.Response, handler: (state: AppState, node: BootstrapNode) => Promise | void ): Promise { + response.setHeader("Cache-Control", "no-store"); const token = bearerToken(request); if (!token) { response.status(401).json({ error: "Missing bearer token" }); @@ -558,13 +622,18 @@ async function withBootstrapNode( const tokenHash = sha256(token); const state = await readState(); - const pillar = state.pillars.find((candidate) => candidate.statusTokenHash === tokenHash); + const now = new Date(); + const pillar = state.pillars.find( + (candidate) => candidate.statusTokenHash === tokenHash || activeEnrollmentMatches(candidate, tokenHash, now) + ); if (pillar) { await handler(state, { nodeType: "pillar", pillar }); return; } - const seedNode = state.seedNodes.find((candidate) => candidate.statusTokenHash === tokenHash); + const seedNode = state.seedNodes.find( + (candidate) => candidate.statusTokenHash === tokenHash || activeEnrollmentMatches(candidate, tokenHash, now) + ); if (seedNode) { await handler(state, { nodeType: "seed", seedNode }); return; @@ -573,20 +642,103 @@ async function withBootstrapNode( response.status(401).json({ error: "Invalid bootstrap token" }); } -function bootstrapInstallScript(origin: string): string { +async function withSecretDownloadNode( + request: express.Request, + response: express.Response, + handler: (state: AppState, node: BootstrapNode) => Promise | void +): Promise { + response.setHeader("Cache-Control", "no-store"); + const token = bearerToken(request); + if (!token) { + response.status(401).json({ error: "Missing secret-download token" }); + return; + } + + const tokenHash = sha256(token); + const now = new Date(); + const state = await readState(); + const pillar = state.pillars.find((candidate) => secretDownloadMatches(candidate, tokenHash, now)); + if (pillar) { + await handler(state, { nodeType: "pillar", pillar }); + return; + } + + const seedNode = state.seedNodes.find((candidate) => secretDownloadMatches(candidate, tokenHash, now)); + if (seedNode) { + await handler(state, { nodeType: "seed", seedNode }); + return; + } + + response.status(401).json({ error: "Invalid or expired secret-download token" }); +} + +async function enrollBootstrapNode(request: express.Request, response: express.Response): Promise { + const token = bearerToken(request); + if (!token) { + response.status(401).json({ error: "Missing enrollment token" }); + return; + } + + const tokenHash = sha256(token); + const now = new Date(); + try { + const credentials = await updateState((state) => { + const pillar = state.pillars.find((candidate) => activeEnrollmentMatches(candidate, tokenHash, now)); + const seedNode = state.seedNodes.find((candidate) => activeEnrollmentMatches(candidate, tokenHash, now)); + const target = pillar ?? seedNode; + if (!target) throw new Error("Invalid, expired, or already used enrollment token"); + return consumeEnrollment(target, token, now); + }); + response.setHeader("Cache-Control", "no-store"); + response.json(credentials); + } catch (error: unknown) { + response.status(401).json({ error: (error as Error).message }); + } +} + +async function completeBootstrap(request: express.Request, response: express.Response): Promise { + const token = bearerToken(request); + if (!token) { + response.status(401).json({ error: "Missing node status token" }); + return; + } + + const tokenHash = sha256(token); + try { + await updateState((state) => { + const target = + state.pillars.find((candidate) => candidate.statusTokenHash === tokenHash) ?? + state.seedNodes.find((candidate) => candidate.statusTokenHash === tokenHash); + if (!target) throw new Error("Invalid node status token"); + clearSecretDownloadToken(target); + }); + response.setHeader("Cache-Control", "no-store"); + response.json({ ok: true }); + } catch (error: unknown) { + response.status(401).json({ error: (error as Error).message }); + } +} + +export function bootstrapInstallScript(origin: string): string { return `#!/usr/bin/env bash set -euo pipefail +umask 077 if [[ "$EUID" -ne 0 ]]; then echo "Run this script as root, usually via sudo." >&2 exit 1 fi -: "\${ZNN_BOOTSTRAP_TOKEN:?Set ZNN_BOOTSTRAP_TOKEN to the node bootstrap token from the testnet builder.}" +ENROLLMENT_SOURCE_FILE="\${ZNN_ENROLLMENT_TOKEN_FILE:-}" +if [[ -z "$ENROLLMENT_SOURCE_FILE" || ! -r "$ENROLLMENT_SOURCE_FILE" || ! -s "$ENROLLMENT_SOURCE_FILE" ]]; then + echo "Set ZNN_ENROLLMENT_TOKEN_FILE to a readable, non-empty mode-600 enrollment token file." >&2 + exit 1 +fi BASE_URL="\${ZNN_TESTNET_URL:-${origin}}" ZNN_DIR="\${ZNN_DIR:-/root/.znn}" DEPLOYMENT_DIR="\${ZNN_DEPLOYMENT_DIR:-/opt/zenon-deployment}" +CREDENTIAL_DIR="\${ZNN_CREDENTIAL_DIR:-/etc/znn-testnet-agent}" DEPLOYMENT_MIN_CPU_CORES="\${ZNN_DEPLOYMENT_MIN_CPU_CORES:-2}" SERVICE_NAME="\${ZNN_SERVICE_NAME:-go-zenon}" RPC_URL="\${ZNN_RPC_URL:-http://127.0.0.1:35997}" @@ -604,23 +756,23 @@ fi cat > /usr/local/bin/znn-testnet-agent <<'AGENT' #!/usr/bin/env bash set -euo pipefail +umask 077 ENV_FILE="\${ZNN_AGENT_ENV_FILE:-/etc/cron.d/znn-testnet-agent}" -if [[ -z "\${ZNN_BOOTSTRAP_TOKEN:-}" && -r "$ENV_FILE" ]]; then +if [[ -r "$ENV_FILE" ]]; then while IFS='=' read -r key value; do case "$key" in - ZNN_BOOTSTRAP_TOKEN|ZNN_TESTNET_URL|ZNN_DIR|ZNN_DEPLOYMENT_DIR|ZNN_DEPLOYMENT_MIN_CPU_CORES|ZNN_RPC_URL|ZNN_SERVICE_NAME|ZNN_AGENT_STATE_DIR|ZNN_BOOTSTRAP_TRACE) + ZNN_TESTNET_URL|ZNN_DIR|ZNN_DEPLOYMENT_DIR|ZNN_CREDENTIAL_DIR|ZNN_DEPLOYMENT_MIN_CPU_CORES|ZNN_RPC_URL|ZNN_SERVICE_NAME|ZNN_AGENT_STATE_DIR|ZNN_BOOTSTRAP_TRACE) [[ -n "$value" ]] && export "$key=$value" ;; esac - done < <(grep -E '^(ZNN_BOOTSTRAP_TOKEN|ZNN_TESTNET_URL|ZNN_DIR|ZNN_DEPLOYMENT_DIR|ZNN_DEPLOYMENT_MIN_CPU_CORES|ZNN_RPC_URL|ZNN_SERVICE_NAME|ZNN_AGENT_STATE_DIR|ZNN_BOOTSTRAP_TRACE)=' "$ENV_FILE" || true) + done < <(grep -E '^(ZNN_TESTNET_URL|ZNN_DIR|ZNN_DEPLOYMENT_DIR|ZNN_CREDENTIAL_DIR|ZNN_DEPLOYMENT_MIN_CPU_CORES|ZNN_RPC_URL|ZNN_SERVICE_NAME|ZNN_AGENT_STATE_DIR|ZNN_BOOTSTRAP_TRACE)=' "$ENV_FILE" || true) fi -: "\${ZNN_BOOTSTRAP_TOKEN:?Missing ZNN_BOOTSTRAP_TOKEN.}" - BASE_URL="\${ZNN_TESTNET_URL:-${origin}}" ZNN_DIR="\${ZNN_DIR:-/root/.znn}" DEPLOYMENT_DIR="\${ZNN_DEPLOYMENT_DIR:-/opt/zenon-deployment}" +CREDENTIAL_DIR="\${ZNN_CREDENTIAL_DIR:-/etc/znn-testnet-agent}" DEPLOYMENT_MIN_CPU_CORES="\${ZNN_DEPLOYMENT_MIN_CPU_CORES:-2}" RPC_URL="\${ZNN_RPC_URL:-http://127.0.0.1:35997}" SERVICE_NAME="\${ZNN_SERVICE_NAME:-go-zenon}" @@ -628,21 +780,113 @@ STATE_DIR="\${ZNN_AGENT_STATE_DIR:-/var/lib/znn-testnet-agent}" BOOTSTRAP_TRACE="\${ZNN_BOOTSTRAP_TRACE:-0}" INSTALL_STATE_FILE="$STATE_DIR/install-state.json" STATUS_FILE="$STATE_DIR/status.json" +PINNED_GO_SOURCE_DIR="$STATE_DIR/go-zenon-source" +ENROLLMENT_TOKEN_FILE="$CREDENTIAL_DIR/enrollment-token" +STATUS_TOKEN_FILE="$CREDENTIAL_DIR/status-token" +SECRET_TOKEN_FILE="$CREDENTIAL_DIR/secret-token" mkdir -p "$STATE_DIR" +chmod 700 "$STATE_DIR" +mkdir -p "$CREDENTIAL_DIR" +chmod 700 "$CREDENTIAL_DIR" + +if [[ ! -r "$STATUS_TOKEN_FILE" || ! -s "$STATUS_TOKEN_FILE" ]] && + [[ ! -r "$ENROLLMENT_TOKEN_FILE" || ! -s "$ENROLLMENT_TOKEN_FILE" ]]; then + echo "No node credential is available. Create a new enrollment token and rerun bootstrap." >&2 + exit 1 +fi if ! [[ "$DEPLOYMENT_MIN_CPU_CORES" =~ ^[0-9]+$ ]] || (( DEPLOYMENT_MIN_CPU_CORES < 1 )); then DEPLOYMENT_MIN_CPU_CORES=2 fi +current_access_token_file() { + if [[ -r "$STATUS_TOKEN_FILE" && -s "$STATUS_TOKEN_FILE" ]]; then + printf '%s\\n' "$STATUS_TOKEN_FILE" + elif [[ -r "$ENROLLMENT_TOKEN_FILE" && -s "$ENROLLMENT_TOKEN_FILE" ]]; then + printf '%s\\n' "$ENROLLMENT_TOKEN_FILE" + else + return 1 + fi +} + +curl_with_token() { + local token_file="$1" curl_config status=0 + shift + [[ -r "$token_file" && -s "$token_file" ]] || return 1 + curl_config="$(mktemp)" + chmod 600 "$curl_config" + printf 'header = "Authorization: Bearer %s"\\n' "$(tr -d '\\r\\n' < "$token_file")" > "$curl_config" + curl --config "$curl_config" "$@" || status=$? + rm -f "$curl_config" + return "$status" +} + auth_get() { - curl -fsSL -H "Authorization: Bearer $ZNN_BOOTSTRAP_TOKEN" "$1" + local token_file + token_file="$(current_access_token_file)" + curl_with_token "$token_file" -fsSL "$1" +} + +secret_get() { + if [[ ! -r "$SECRET_TOKEN_FILE" || ! -s "$SECRET_TOKEN_FILE" ]]; then + echo "A short-lived secret-download token is required. Create a new enrollment token and rerun bootstrap." >&2 + return 1 + fi + curl_with_token "$SECRET_TOKEN_FILE" -fsSL "$1" +} + +secret_file_valid() { + local file="$1" kind="$2" value + [[ -r "$file" && -s "$file" ]] || return 1 + + case "$kind" in + producer-wallet) + jq -e 'type == "object" and length > 0' "$file" >/dev/null 2>&1 + ;; + producer-password) + value="$(tr -d '\\r\\n' < "$file")" + [[ "$value" =~ ^[A-Za-z0-9_-]{24}$ ]] + ;; + network-private-key) + value="$(tr -d '\\r\\n' < "$file")" + [[ "$value" =~ ^[0-9a-fA-F]{64}$ ]] + ;; + *) + return 1 + ;; + esac +} + +install_secret_file() { + local url="$1" destination="$2" kind="$3" destination_dir temp_file="" + + if secret_file_valid "$destination" "$kind"; then + chmod 600 "$destination" + return 0 + fi + + destination_dir="$(dirname -- "$destination")" + if ! temp_file="$(mktemp "$destination_dir/.$(basename -- "$destination").XXXXXX")"; then + return 1 + fi + if ! chmod 600 "$temp_file" || + ! secret_get "$url" > "$temp_file" || + ! secret_file_valid "$temp_file" "$kind"; then + rm -f -- "$temp_file" + return 1 + fi + if ! mv -f -- "$temp_file" "$destination"; then + rm -f -- "$temp_file" + return 1 + fi } try_auth_get() { - local tmp code + local token_file tmp code + token_file="$(current_access_token_file)" tmp="$(mktemp)" - code="$(curl -sS -H "Authorization: Bearer $ZNN_BOOTSTRAP_TOKEN" -w "%{http_code}" -o "$tmp" "$1" || true)" + code="$(curl_with_token "$token_file" -sS -w "%{http_code}" -o "$tmp" "$1" || true)" if [[ "$code" == "200" ]]; then cat "$tmp" rm -f "$tmp" @@ -652,6 +896,45 @@ try_auth_get() { return 1 } +enroll_node() { + local response_file="" status_token_tmp="" secret_token_tmp="" + [[ -r "$ENROLLMENT_TOKEN_FILE" && -s "$ENROLLMENT_TOKEN_FILE" ]] || return 0 + if ! response_file="$(mktemp)"; then + return 1 + fi + if ! status_token_tmp="$(mktemp "$CREDENTIAL_DIR/.status-token.XXXXXX")"; then + rm -f "$response_file" + return 1 + fi + if ! secret_token_tmp="$(mktemp "$CREDENTIAL_DIR/.secret-token.XXXXXX")"; then + rm -f "$response_file" "$status_token_tmp" + return 1 + fi + if ! chmod 600 "$response_file" "$status_token_tmp" "$secret_token_tmp" || + ! curl_with_token "$ENROLLMENT_TOKEN_FILE" -fsS -X POST -o "$response_file" "$BASE_URL/api/bootstrap/enroll" || + ! jq -er '.statusToken | select(type == "string" and length > 0)' "$response_file" > "$status_token_tmp" || + ! jq -er '.secretToken | select(type == "string" and length > 0)' "$response_file" > "$secret_token_tmp"; then + rm -f "$response_file" "$status_token_tmp" "$secret_token_tmp" + return 1 + fi + if ! mv -f "$status_token_tmp" "$STATUS_TOKEN_FILE"; then + rm -f "$response_file" "$status_token_tmp" "$secret_token_tmp" + return 1 + fi + if ! mv -f "$secret_token_tmp" "$SECRET_TOKEN_FILE"; then + rm -f "$response_file" "$secret_token_tmp" "$STATUS_TOKEN_FILE" + return 1 + fi + rm -f "$response_file" "$ENROLLMENT_TOKEN_FILE" +} + +complete_enrollment() { + [[ -r "$STATUS_TOKEN_FILE" && -s "$STATUS_TOKEN_FILE" ]] || return 0 + if curl_with_token "$STATUS_TOKEN_FILE" -fsS -X POST "$BASE_URL/api/bootstrap/complete" >/dev/null; then + rm -f "$SECRET_TOKEN_FILE" + fi +} + rpc() { curl -fs --max-time 5 -H "Content-Type: application/json" \\ -d "{\\"jsonrpc\\":\\"2.0\\",\\"id\\":1,\\"method\\":\\"$1\\",\\"params\\":[]}" \\ @@ -691,9 +974,39 @@ patch_deployment_preflight() { fi } +clone_pinned_repository() { + local repo="$1" ref="$2" expected_commit="$3" destination="$4" actual_commit + + if ! [[ "$repo" =~ ^https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(\\.git)?$ ]]; then + echo "Refusing non-GitHub HTTPS repository: $repo" >&2 + return 1 + fi + if ! [[ "$expected_commit" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then + echo "A full immutable commit pin is required for $repo." >&2 + return 1 + fi + + rm -rf -- "$destination" + git init -q "$destination" + git -C "$destination" remote add origin "$repo" + if ! git -C "$destination" fetch --quiet --depth 1 origin "$ref"; then + echo "Failed to fetch ref '$ref' from $repo." >&2 + rm -rf -- "$destination" + return 1 + fi + git -C "$destination" checkout --quiet --detach FETCH_HEAD + actual_commit="$(git -C "$destination" rev-parse HEAD)" + if [[ "\${actual_commit,,}" != "\${expected_commit,,}" ]]; then + echo "Commit pin mismatch for $repo: ref '$ref' resolved to $actual_commit." >&2 + rm -rf -- "$destination" + return 1 + fi + git -C "$destination" branch -f znn-pinned-release "$actual_commit" >/dev/null +} + install_release() { local manifest="$1" - local event_id node_type go_repo go_ref go_commit deployment_repo deployment_ref genesis_url config_url producer_url producer_password_url network_private_key_url wipe_data apply_at desired_key installed_key binary_key installed_binary_key binary_missing artifacts_ready + local event_id node_type go_repo go_ref go_commit deployment_repo deployment_ref deployment_commit genesis_url config_url config_tmp producer_url producer_password_url network_private_key_url wipe_data apply_at desired_key installed_key binary_key installed_binary_key binary_missing artifacts_ready event_id="$(printf '%s' "$manifest" | jq -r '.eventId')" node_type="$(printf '%s' "$manifest" | jq -r '.nodeType // "pillar"')" @@ -702,6 +1015,7 @@ install_release() { go_commit="$(printf '%s' "$manifest" | jq -r '.goZenon.commit // empty')" deployment_repo="$(printf '%s' "$manifest" | jq -r '.deployment.repoUrl')" deployment_ref="$(printf '%s' "$manifest" | jq -r '.deployment.ref')" + deployment_commit="$(printf '%s' "$manifest" | jq -r '.deployment.commit // empty')" wipe_data="$(printf '%s' "$manifest" | jq -r '.actions.wipeData // false')" apply_at="$(printf '%s' "$manifest" | jq -r '.actions.applyAt // empty')" genesis_url="$(printf '%s' "$manifest" | jq -r '.genesisUrl')" @@ -709,8 +1023,8 @@ install_release() { producer_url="$(printf '%s' "$manifest" | jq -r '.producerKeyFileUrl // empty')" producer_password_url="$(printf '%s' "$manifest" | jq -r '.producerPasswordUrl // empty')" network_private_key_url="$(printf '%s' "$manifest" | jq -r '.networkPrivateKeyUrl // empty')" - desired_key="$(printf '%s' "$manifest" | jq -r '[.eventId, (.nodeType // "pillar"), .goZenon.repoUrl, .goZenon.ref, (.goZenon.commit // ""), .deployment.repoUrl, .deployment.ref, (.actions.wipeData // false), (.actions.applyAt // "")] | @tsv')" - binary_key="$(printf '%s' "$manifest" | jq -r '[.goZenon.repoUrl, .goZenon.ref, (.goZenon.commit // ""), .deployment.repoUrl, .deployment.ref] | @tsv')" + desired_key="$(printf '%s' "$manifest" | jq -r '[.eventId, (.nodeType // "pillar"), .goZenon.repoUrl, .goZenon.ref, (.goZenon.commit // ""), .deployment.repoUrl, .deployment.ref, (.deployment.commit // ""), (.actions.wipeData // false), (.actions.applyAt // "")] | @tsv')" + binary_key="$(printf '%s' "$manifest" | jq -r '[.goZenon.repoUrl, .goZenon.ref, (.goZenon.commit // ""), .deployment.repoUrl, .deployment.ref, (.deployment.commit // "")] | @tsv')" installed_key="$(jq -r '.desiredKey // empty' "$INSTALL_STATE_FILE" 2>/dev/null || true)" installed_binary_key="$(jq -r '.binaryKey // empty' "$INSTALL_STATE_FILE" 2>/dev/null || true)" binary_missing=false @@ -720,9 +1034,11 @@ install_release() { artifacts_ready=false if [[ -s "$ZNN_DIR/genesis.json" && -s "$ZNN_DIR/config.json" ]]; then - if [[ "$node_type" == "seed" && -s "$ZNN_DIR/network-private-key" ]]; then + if [[ "$node_type" == "seed" ]] && secret_file_valid "$ZNN_DIR/network-private-key" network-private-key; then artifacts_ready=true - elif [[ "$node_type" != "seed" && -s "$ZNN_DIR/wallet/producer.json" && -s "$ZNN_DIR/wallet/producer-password.txt" ]]; then + elif [[ "$node_type" != "seed" ]] && + secret_file_valid "$ZNN_DIR/wallet/producer.json" producer-wallet && + secret_file_valid "$ZNN_DIR/wallet/producer-password.txt" producer-password; then artifacts_ready=true fi fi @@ -731,18 +1047,33 @@ install_release() { return 0 fi + mkdir -p "$ZNN_DIR/wallet" + chmod 700 "$ZNN_DIR" "$ZNN_DIR/wallet" + if [[ -n "$producer_url" ]]; then + install_secret_file "$producer_url" "$ZNN_DIR/wallet/producer.json" producer-wallet + fi + if [[ -n "$producer_password_url" ]]; then + install_secret_file "$producer_password_url" "$ZNN_DIR/wallet/producer-password.txt" producer-password + fi + if [[ -n "$network_private_key_url" ]]; then + install_secret_file "$network_private_key_url" "$ZNN_DIR/network-private-key" network-private-key + fi + [[ -f "$ZNN_DIR/wallet/producer.json" ]] && chmod 600 "$ZNN_DIR/wallet/producer.json" + [[ -f "$ZNN_DIR/wallet/producer-password.txt" ]] && chmod 600 "$ZNN_DIR/wallet/producer-password.txt" + [[ -f "$ZNN_DIR/network-private-key" ]] && chmod 600 "$ZNN_DIR/network-private-key" + if command -v systemctl >/dev/null 2>&1; then systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true fi if [[ "$binary_key" != "$installed_binary_key" || "$binary_missing" == "true" ]]; then - rm -rf "$DEPLOYMENT_DIR" - git clone --depth 1 --branch "$deployment_ref" "$deployment_repo" "$DEPLOYMENT_DIR" + clone_pinned_repository "$deployment_repo" "$deployment_ref" "$deployment_commit" "$DEPLOYMENT_DIR" + clone_pinned_repository "$go_repo" "$go_ref" "$go_commit" "$PINNED_GO_SOURCE_DIR" chmod +x "$DEPLOYMENT_DIR/zenon.sh" patch_deployment_preflight cd "$DEPLOYMENT_DIR" - if ! ./zenon.sh --deploy zenon "$go_repo" "$go_ref"; then + if ! ./zenon.sh --deploy zenon "file://$PINNED_GO_SOURCE_DIR" "znn-pinned-release"; then echo "zenon.sh deployment failed. Last deployment log lines:" >&2 tail -120 "$DEPLOYMENT_DIR/.znnsh.log" >&2 2>/dev/null || true return 1 @@ -757,17 +1088,14 @@ install_release() { wipe_data_dir fi - mkdir -p "$ZNN_DIR/wallet" auth_get "$genesis_url" > "$ZNN_DIR/genesis.json" - auth_get "$config_url" > "$ZNN_DIR/config.json" - if [[ -n "$producer_url" ]]; then - auth_get "$producer_url" > "$ZNN_DIR/wallet/producer.json" - fi - if [[ -n "$producer_password_url" ]]; then - auth_get "$producer_password_url" > "$ZNN_DIR/wallet/producer-password.txt" - fi - if [[ -n "$network_private_key_url" ]]; then - auth_get "$network_private_key_url" > "$ZNN_DIR/network-private-key" + config_tmp="$ZNN_DIR/.config.json.$$.tmp" + auth_get "$config_url" > "$config_tmp" + if [[ "$node_type" == "pillar" ]]; then + jq --rawfile password "$ZNN_DIR/wallet/producer-password.txt" '.Producer.Password = ($password | rtrimstr("\\n"))' "$config_tmp" > "$ZNN_DIR/config.json" + rm -f "$config_tmp" + else + mv "$config_tmp" "$ZNN_DIR/config.json" fi chmod 700 "$ZNN_DIR" "$ZNN_DIR/wallet" @@ -790,6 +1118,7 @@ install_release() { --arg goCommit "$go_commit" \\ --arg deploymentRepo "$deployment_repo" \\ --arg deploymentRef "$deployment_ref" \\ + --arg deploymentCommit "$deployment_commit" \\ --arg nodeType "$node_type" \\ --arg applyAt "$apply_at" \\ --argjson wipeData "$wipe_data" \\ @@ -800,7 +1129,7 @@ install_release() { installedAt: $installedAt, nodeType: $nodeType, goZenon: { repoUrl: $goRepo, ref: $goRef, commit: $goCommit }, - deployment: { repoUrl: $deploymentRepo, ref: $deploymentRef }, + deployment: { repoUrl: $deploymentRepo, ref: $deploymentRef, commit: $deploymentCommit }, actions: ({ wipeData: $wipeData } + (if $applyAt == "" then {} else { applyAt: $applyAt } end)) }' > "$INSTALL_STATE_FILE" } @@ -810,6 +1139,8 @@ report_status() { local waiting="\${2:-false}" local event_id go_repo go_ref go_commit sync_json network_json process_json service_active logs error_count warn_count recent_json payload + [[ -r "$STATUS_TOKEN_FILE" && -s "$STATUS_TOKEN_FILE" ]] || return 0 + if [[ -n "$manifest" ]]; then event_id="$(printf '%s' "$manifest" | jq -r '.eventId')" go_repo="$(printf '%s' "$manifest" | jq -r '.goZenon.repoUrl')" @@ -888,8 +1219,7 @@ report_status() { } }')" - curl -fsS -X POST "$BASE_URL/api/bootstrap/status" \\ - -H "Authorization: Bearer $ZNN_BOOTSTRAP_TOKEN" \\ + curl_with_token "$STATUS_TOKEN_FILE" -fsS -X POST "$BASE_URL/api/bootstrap/status" \\ -H "Content-Type: application/json" \\ -d "$payload" >/dev/null || true @@ -914,18 +1244,29 @@ if [[ -n "$apply_at" ]]; then fi fi +if [[ ! -r "$STATUS_TOKEN_FILE" || ! -s "$STATUS_TOKEN_FILE" ]]; then + enroll_node + manifest="$(try_auth_get "$BASE_URL/api/bootstrap/manifest")" +fi + if ! install_release "$manifest"; then report_status "$manifest" false exit 1 fi +complete_enrollment report_status "$manifest" false AGENT chmod 700 /usr/local/bin/znn-testnet-agent +install -d -m 700 "$CREDENTIAL_DIR" +rm -f "$CREDENTIAL_DIR/status-token" "$CREDENTIAL_DIR/secret-token" +install -m 600 "$ENROLLMENT_SOURCE_FILE" "$CREDENTIAL_DIR/enrollment-token" +rm -f -- "$ENROLLMENT_SOURCE_FILE" + cat > /etc/cron.d/znn-testnet-agent < 0) app.set("trust proxy", TRUST_PROXY_HOPS); + app.use((_request, response, next) => { + response.setHeader( + "Content-Security-Policy", + "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'" + ); + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Permissions-Policy", "camera=(), geolocation=(), microphone=()"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); + if (process.env.COOKIE_SECURE === "true") { + response.setHeader("Strict-Transport-Security", "max-age=31536000"); + } + next(); + }); app.use(cookieParser()); app.use(express.json({ limit: "1mb" })); @@ -1041,6 +1398,8 @@ async function main() { app.post("/api/bootstrap/status", receiveNodeStatus); app.post("/api/node/status", receiveNodeStatus); + app.post("/api/bootstrap/enroll", enrollBootstrapNode); + app.post("/api/bootstrap/complete", completeBootstrap); app.get("/api/bootstrap/install.sh", (request, response) => { response.setHeader("Content-Type", "text/x-shellscript; charset=utf-8"); @@ -1087,7 +1446,7 @@ async function main() { }); app.get("/api/bootstrap/producer.json", async (request, response) => { - await withBootstrapNode(request, response, (_state, node) => { + await withSecretDownloadNode(request, response, (_state, node) => { if (node.nodeType !== "pillar") { response.status(404).json({ error: "Seed nodes do not have producer wallets" }); return; @@ -1097,7 +1456,7 @@ async function main() { }); app.get("/api/bootstrap/producer-password.txt", async (request, response) => { - await withBootstrapNode(request, response, (_state, node) => { + await withSecretDownloadNode(request, response, (_state, node) => { if (node.nodeType !== "pillar") { response.status(404).json({ error: "Seed nodes do not have producer wallets" }); return; @@ -1109,7 +1468,7 @@ async function main() { }); app.get("/api/bootstrap/network-private-key", async (request, response) => { - await withBootstrapNode(request, response, (_state, node) => { + await withSecretDownloadNode(request, response, (_state, node) => { if (node.nodeType !== "seed") { response.status(404).json({ error: "Pillar nodes do not have managed network private keys" }); return; @@ -1121,18 +1480,34 @@ async function main() { }); app.post("/api/auth/login", async (request, response) => { + const sourceRetry = loginRetryAfterMs(request); + if (sourceRetry > 0) { + sendLoginRateLimit(response, sourceRetry); + return; + } + const parsed = loginSchema.safeParse(request.body); if (!parsed.success) { + recordLoginAttempt(request); response.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid login" }); return; } + const normalizedUsername = parsed.data.username.trim().toLowerCase(); + const retryAfterMs = loginRetryAfterMs(request, normalizedUsername); + if (retryAfterMs > 0) { + sendLoginRateLimit(response, retryAfterMs); + return; + } + + recordLoginAttempt(request, normalizedUsername); const result = await login(parsed.data.username, parsed.data.password); if (!result) { response.status(401).json({ error: "Invalid username or password" }); return; } + clearLoginAttempts(request, normalizedUsername); setSessionCookie(response, result.token); response.json({ user: result.user }); }); @@ -1149,14 +1524,31 @@ async function main() { const pillar = state.pillars.find((candidate) => candidate.userId === user.id); const seedNode = state.seedNodes.find((candidate) => candidate.userId === user.id); const bootstrapRecord = pillar ?? seedNode; + const enrollment = bootstrapRecord ? activeEnrollment(bootstrapRecord) : undefined; + response.setHeader("Cache-Control", "no-store"); response.json({ user, pillar: pillar ? toPublicPillar(pillar) : undefined, seedNode: seedNode ? publicSeedNode(seedNode) : undefined, - bootstrap: bootstrapRecord?.statusTokenCipher ? { statusToken: statusToken(bootstrapRecord) } : undefined + bootstrap: bootstrapRecord ? { enrollment } : undefined }); }); + app.post("/api/bootstrap/enrollment-token", requireAuth(), async (request, response) => { + const user = (request as AuthedRequest).user; + try { + const enrollment = await updateState((state) => { + const target = nodeRecordForUser(state, user.id); + if (!target) throw new Error("No node registered"); + return rotateNodeCredentials(target); + }); + response.setHeader("Cache-Control", "no-store"); + response.json({ enrollment }); + } catch (error: unknown) { + response.status(404).json({ error: (error as Error).message }); + } + }); + app.post("/api/pillar", requireAuth(), async (request, response) => { const user = (request as AuthedRequest).user; const parsed = nodeRegistrationSchema.safeParse(request.body); @@ -1244,7 +1636,8 @@ async function main() { ...state.settings, ...parsed.data, minPillars: Math.min(parsed.data.minPillars, parsed.data.expectedPillars), - goZenonCommit: parsed.data.goZenonCommit || undefined, + goZenonCommit: parsed.data.goZenonCommit?.toLowerCase() || undefined, + deploymentCommit: parsed.data.deploymentCommit?.toLowerCase() || undefined, bootstrapPeers }; if (genesisSettingsKey(state.settings) !== beforeGenesisSettings) { @@ -1278,10 +1671,9 @@ async function main() { return; } - const admin = (request as AuthedRequest).user; const userId = String(request.params.userId); try { - await resetAccountPassword(userId, parsed.data.password, admin.id); + await resetAccountPassword(userId, parsed.data.password, sessionTokenFromRequest(request)); const state = await readState(); response.json({ user: managedUsers(state).find((candidate) => candidate.id === userId) }); } catch (error: unknown) { @@ -1417,32 +1809,37 @@ async function main() { }); app.post("/api/admin/publish", requireAuth("admin"), async (_request, response) => { - const result = await updateState((state) => { - const genesis = state.finalizedGenesis?.genesis ?? buildGenesis(state.settings, state.pillars); - const now = new Date().toISOString(); - if (!state.finalizedGenesis) { - state.finalizedGenesis = { - finalizedAt: now, - genesis - }; - } + try { + const result = await updateState((state) => { + requireReleasePins(state.settings); + const genesis = state.finalizedGenesis?.genesis ?? buildGenesis(state.settings, state.pillars); + const now = new Date().toISOString(); + if (!state.finalizedGenesis) { + state.finalizedGenesis = { + finalizedAt: now, + genesis + }; + } - const settings = settingsSnapshot(state.settings); - state.publishedArtifacts = { - publishedAt: now, - genesis, - config: buildNodeConfig(settings), - nodePlan: buildPublishedNodePlan(settings, now, state.finalizedGenesis.finalizedAt), - settings, - chainIdentifier: settings.chainIdentifier, - seeders: [...settings.seeders], - bootstrapPeers: [...(settings.bootstrapPeers ?? [])] - }; - state.settings.wipeDataOnPublish = false; - state.settings.releaseApplyAtSec = undefined; - return state.publishedArtifacts; - }); - response.json({ published: publishedInfo(result) }); + const settings = settingsSnapshot(state.settings); + state.publishedArtifacts = { + publishedAt: now, + genesis, + config: buildNodeConfig(settings), + nodePlan: buildPublishedNodePlan(settings, now, state.finalizedGenesis.finalizedAt), + settings, + chainIdentifier: settings.chainIdentifier, + seeders: [...settings.seeders], + bootstrapPeers: [...(settings.bootstrapPeers ?? [])] + }; + state.settings.wipeDataOnPublish = false; + state.settings.releaseApplyAtSec = undefined; + return state.publishedArtifacts; + }); + response.json({ published: publishedInfo(result) }); + } catch (error: unknown) { + response.status(409).json({ error: (error as Error).message }); + } }); app.get("/api/admin/genesis.json", requireAuth("admin"), async (_request, response) => { @@ -1472,7 +1869,9 @@ async function main() { }); } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/src/server/login-rate-limit.ts b/src/server/login-rate-limit.ts new file mode 100644 index 0000000..0d45df4 --- /dev/null +++ b/src/server/login-rate-limit.ts @@ -0,0 +1,88 @@ +interface LoginAttemptEntry { + attempts: number; + windowStartedAt: number; + blockedUntil: number; +} + +interface LoginAttemptLimiterOptions { + maxAttempts: number; + windowMs: number; + lockoutMs: number; + baseDelayMs?: number; + maxDelayMs?: number; + maxEntries?: number; +} + +export class LoginAttemptLimiter { + private readonly entries = new Map(); + private readonly maxAttempts: number; + private readonly windowMs: number; + private readonly lockoutMs: number; + private readonly baseDelayMs: number; + private readonly maxDelayMs: number; + private readonly maxEntries: number; + + constructor(options: LoginAttemptLimiterOptions) { + this.maxAttempts = options.maxAttempts; + this.windowMs = options.windowMs; + this.lockoutMs = options.lockoutMs; + this.baseDelayMs = options.baseDelayMs ?? 500; + this.maxDelayMs = options.maxDelayMs ?? 8000; + this.maxEntries = options.maxEntries ?? 10_000; + } + + retryAfterMs(key: string, now = Date.now()): number { + const entry = this.entries.get(key); + if (!entry) return 0; + + if (now >= entry.windowStartedAt + this.windowMs && now >= entry.blockedUntil) { + this.entries.delete(key); + return 0; + } + return Math.max(0, entry.blockedUntil - now); + } + + recordAttempt(key: string, now = Date.now()): number { + this.prune(now); + const existing = this.entries.get(key); + const entry = + existing && now < existing.windowStartedAt + this.windowMs + ? existing + : { attempts: 0, windowStartedAt: now, blockedUntil: now }; + + entry.attempts += 1; + const delay = + entry.attempts >= this.maxAttempts + ? this.lockoutMs + : Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (entry.attempts - 1)); + entry.blockedUntil = now + delay; + this.entries.delete(key); + this.entries.set(key, entry); + this.enforceBound(); + return delay; + } + + success(key: string): void { + this.entries.delete(key); + } + + get size(): number { + return this.entries.size; + } + + private prune(now: number): void { + for (const [key, entry] of this.entries) { + if (now >= entry.windowStartedAt + this.windowMs && now >= entry.blockedUntil) { + this.entries.delete(key); + } + } + } + + private enforceBound(): void { + while (this.entries.size > this.maxEntries) { + const oldestKey = this.entries.keys().next().value as string | undefined; + if (oldestKey === undefined) return; + this.entries.delete(oldestKey); + } + } +} diff --git a/src/server/origin.ts b/src/server/origin.ts new file mode 100644 index 0000000..5ccf54b --- /dev/null +++ b/src/server/origin.ts @@ -0,0 +1,27 @@ +function isLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"; +} + +export function configuredPublicOrigin(environment: NodeJS.ProcessEnv = process.env): string | undefined { + const raw = environment.PUBLIC_BASE_URL?.trim(); + if (!raw) { + if (environment.NODE_ENV === "production") { + throw new Error("PUBLIC_BASE_URL must be set in production."); + } + return undefined; + } + + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error("PUBLIC_BASE_URL must be a valid absolute URL."); + } + if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + throw new Error("PUBLIC_BASE_URL must contain only an HTTP(S) origin without credentials, path, query, or fragment."); + } + if (environment.NODE_ENV === "production" && url.protocol !== "https:" && !isLoopbackHost(url.hostname)) { + throw new Error("PUBLIC_BASE_URL must use HTTPS in production unless it is loopback-only."); + } + return url.origin; +} diff --git a/src/server/releases.ts b/src/server/releases.ts new file mode 100644 index 0000000..a1c0d1c --- /dev/null +++ b/src/server/releases.ts @@ -0,0 +1,16 @@ +import type { NetworkSettings, NetworkSettingsSnapshot } from "../shared/types.js"; + +const GIT_COMMIT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; + +export function isPinnedGitCommit(value?: string): value is string { + return Boolean(value && GIT_COMMIT_PATTERN.test(value)); +} + +export function requireReleasePins(settings: NetworkSettings | NetworkSettingsSnapshot): void { + const missing: string[] = []; + if (!isPinnedGitCommit(settings.goZenonCommit)) missing.push("go-zenon"); + if (!isPinnedGitCommit(settings.deploymentCommit)) missing.push("deployment"); + if (missing.length > 0) { + throw new Error(`Publish requires full immutable commit pins for: ${missing.join(", ")}.`); + } +} diff --git a/src/server/seeders.ts b/src/server/seeders.ts index 0ca8daf..a0ecdba 100644 --- a/src/server/seeders.ts +++ b/src/server/seeders.ts @@ -1,4 +1,4 @@ -import { isIP } from "node:net"; +import { BlockList, isIP } from "node:net"; import { enodeFromPublicKey, multiaddrFromPublicKey, normalizePublicKey } from "./libp2p.js"; import type { SeedNodeProbeResult } from "../shared/types.js"; @@ -16,6 +16,35 @@ interface JsonRpcResponse { result?: unknown; } +const blockedProbeTargets = new BlockList(); +for (const [address, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["224.0.0.0", 4], + ["240.0.0.0", 4] +] as Array<[string, number]>) { + blockedProbeTargets.addSubnet(address, prefix, "ipv4"); +} +for (const [address, prefix] of [ + ["::", 128], + ["::1", 128], + ["64:ff9b::", 96], + ["64:ff9b:1::", 48], + ["fc00::", 7], + ["fe80::", 10], + ["ff00::", 8], + ["2001:db8::", 32] +] as Array<[string, number]>) { + blockedProbeTargets.addSubnet(address, prefix, "ipv6"); +} + function hostForUrl(ip: string): string { return isIP(ip) === 6 ? `[${ip}]` : ip; } @@ -46,10 +75,17 @@ export function validateSeedNodeIp(ip: string): boolean { return isIP(ip.trim()) !== 0; } +export function validateSeedProbeIp(ip: string): boolean { + const normalized = ip.trim(); + const family = isIP(normalized); + if (family === 0) return false; + return !blockedProbeTargets.check(normalized, family === 4 ? "ipv4" : "ipv6"); +} + export async function probeSeedNode(input: SeedNodeProbeInput): Promise { const ip = input.ip.trim(); - if (!validateSeedNodeIp(ip)) { - throw new Error("Seed node must be an IP address"); + if (!validateSeedProbeIp(ip)) { + throw new Error("Seed probe target must be a publicly routable IP address"); } const rpcUrl = `http://${hostForUrl(ip)}:${input.rpcPort}`; @@ -64,6 +100,7 @@ export async function probeSeedNode(input: SeedNodeProbeInput): Promise): AppState { } async function ensureDataDir(): Promise { - await mkdir(DATA_DIR, { recursive: true }); + await mkdir(DATA_DIR, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + await chmod(DATA_DIR, PRIVATE_DIRECTORY_MODE); + try { + await chmod(STATE_FILE, PRIVATE_FILE_MODE); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } } function findJsonValueEnd(content: string, start: number): number | undefined { @@ -192,9 +202,10 @@ function parseStateContent(content: string): { state: AppState; recovered: boole async function writeAtomic(filePath: string, content: string): Promise { await ensureDataDir(); const tempFile = path.join(DATA_DIR, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`); - await writeFile(tempFile, content, "utf8"); + await writeFile(tempFile, content, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" }); try { await rename(tempFile, filePath); + await chmod(filePath, PRIVATE_FILE_MODE); } catch (error) { await unlink(tempFile).catch(() => undefined); throw error; diff --git a/src/shared/types.ts b/src/shared/types.ts index 0e8ea56..1861822 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -31,6 +31,12 @@ export interface PillarRecord { producerIndex: number; statusTokenHash?: string; statusTokenCipher?: string; + enrollmentTokenHash?: string; + enrollmentTokenCipher?: string; + enrollmentTokenExpiresAt?: string; + enrollmentTokenUsedAt?: string; + secretDownloadTokenHash?: string; + secretDownloadTokenExpiresAt?: string; nodeStatus?: PillarNodeStatus; packageDownloadedAt?: string; createdAt: string; @@ -48,6 +54,12 @@ export interface SeedNodeRecord { networkPrivateKeyCipher: string; statusTokenHash?: string; statusTokenCipher?: string; + enrollmentTokenHash?: string; + enrollmentTokenCipher?: string; + enrollmentTokenExpiresAt?: string; + enrollmentTokenUsedAt?: string; + secretDownloadTokenHash?: string; + secretDownloadTokenExpiresAt?: string; nodeStatus?: PillarNodeStatus; packageDownloadedAt?: string; createdAt: string; @@ -72,6 +84,7 @@ export interface NetworkSettings { goZenonCommit?: string; deploymentRepo: string; deploymentRef: string; + deploymentCommit?: string; releaseApplyAtSec?: number; wipeDataOnPublish: boolean; sporkAddress: string; @@ -155,6 +168,7 @@ export interface ReleaseTarget { deployment: { repoUrl: string; ref: string; + commit?: string; }; } @@ -270,7 +284,10 @@ export interface UserOverview { pillar?: PublicPillar; seedNode?: PublicSeedNode; bootstrap?: { - statusToken: string; + enrollment?: { + token: string; + expiresAt: string; + }; }; } diff --git a/src/web/App.tsx b/src/web/App.tsx index 658ac62..da38c3a 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -76,11 +76,14 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } -function bootstrapCommand(token: string): string { +function bootstrapCommand(): string { const baseUrl = publicUrl("/").replace(/\/$/, ""); - return `curl -fsSL ${shellQuote(publicUrl("/api/bootstrap/install.sh"))} | sudo env ZNN_BOOTSTRAP_TOKEN=${shellQuote( - token - )} ZNN_TESTNET_URL=${shellQuote(baseUrl)} bash`; + const tokenFile = "/run/znn-testnet-enrollment-token"; + return `read -rsp 'Enrollment token: ' ZNN_ENROLLMENT_TOKEN && printf '\\n' && printf '%s' "$ZNN_ENROLLMENT_TOKEN" | sudo install -m 600 /dev/stdin ${shellQuote( + tokenFile + )} && unset ZNN_ENROLLMENT_TOKEN && curl -fsSL ${shellQuote( + publicUrl("/api/bootstrap/install.sh") + )} | sudo env ZNN_ENROLLMENT_TOKEN_FILE=${shellQuote(tokenFile)} ZNN_TESTNET_URL=${shellQuote(baseUrl)} bash`; } function toUtcDateTimeInput(seconds?: number): string { @@ -115,6 +118,7 @@ function settingsKey(settings: PublicNetworkSettings): string { goZenonCommit: settings.goZenonCommit || "", deploymentRepo: settings.deploymentRepo, deploymentRef: settings.deploymentRef, + deploymentCommit: settings.deploymentCommit || "", wipeDataOnPublish: settings.wipeDataOnPublish, seeders: settings.seeders.filter(Boolean), bootstrapPeers: settings.bootstrapPeers.filter(Boolean), @@ -268,7 +272,9 @@ function OperatorView({ session, refresh }: { session: UserOverview; refresh: () const [registerSeedNode, setRegisterSeedNode] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const command = session.bootstrap?.statusToken ? bootstrapCommand(session.bootstrap.statusToken) : ""; + const [rotatingEnrollment, setRotatingEnrollment] = useState(false); + const enrollment = session.bootstrap?.enrollment; + const command = enrollment ? bootstrapCommand() : ""; const hasNode = Boolean(session.pillar || session.seedNode); const displayName = session.pillar?.pillarName ?? session.seedNode?.nodeName ?? "Register Node"; @@ -297,6 +303,19 @@ function OperatorView({ session, refresh }: { session: UserOverview; refresh: () } } + async function rotateEnrollment() { + setError(""); + setRotatingEnrollment(true); + try { + await api("/api/bootstrap/enrollment-token", { method: "POST" }); + await refresh(); + } catch (err) { + setError((err as Error).message); + } finally { + setRotatingEnrollment(false); + } + } + return (
@@ -316,13 +335,22 @@ function OperatorView({ session, refresh }: { session: UserOverview; refresh: () - {command ? ( - + + + ) : ( + - ) : null} + )}
- {command ? ( + {enrollment ? (
@@ -331,6 +359,8 @@ function OperatorView({ session, refresh }: { session: UserOverview; refresh: ()
+ Enrollment token · expires {formatUtc(enrollment.expiresAt)} +
{enrollment.token}
{command}
) : null} @@ -348,13 +378,22 @@ function OperatorView({ session, refresh }: { session: UserOverview; refresh: () - {command ? ( - + + + ) : ( + - ) : null} + )} - {command ? ( + {enrollment ? (
@@ -363,6 +402,8 @@ function OperatorView({ session, refresh }: { session: UserOverview; refresh: ()
+ Enrollment token · expires {formatUtc(enrollment.expiresAt)} +
{enrollment.token}
{command}
) : null} @@ -1046,7 +1087,16 @@ function SettingsForm({ className="mono" value={draft.goZenonCommit ?? ""} onChange={(event) => setDraft({ ...draft, goZenonCommit: event.target.value })} - placeholder="optional" + placeholder="required full commit" + /> + +