From b285cdb28876d20fbc75cd59c7db8fb8f43c416e Mon Sep 17 00:00:00 2001 From: hermes-gadget Date: Thu, 1 Oct 2026 00:09:44 +0100 Subject: [PATCH] fix(security,ops): close ukmesh auth/users findings (#50 #51 #52 #53 #58) (#103) * docs: add C5a mission handoff * security: classify channel keys and baseline reviewed findings * security: revalidate MQTT auth and revoke owner sessions * test(ops): cover concurrent newuser grant updates * fix(ops): preserve executable newuser test * fix(ops): cover partial newuser rollback failures * fix(owner): preserve password whitespace during login * docs: add C5c mission handoff * test(cache): drop stale authCache policy entry (cache removed by auth revalidation) * docs: keep HANDOFF on current mission --------- Co-authored-by: gadgethd <111318106+gadgethd@users.noreply.github.com> Co-authored-by: hermes-gadget <284726080+hermes-gadget@users.noreply.github.com> --- .github/workflows/ci.yml | 9 + .gitleaks.toml | 35 --- .gitleaksignore | 51 +++ HANDOFF.md | 58 ++-- README.md | 2 +- backend/package.json | 1 + backend/src/api/routes.ts | 5 +- backend/src/api/routes/owner.test.ts | 117 +++++++ backend/src/cache/policyRegistry.ts | 6 - backend/src/mqtt/channelRegistry.test.ts | 16 + backend/src/mqtt/channelRegistry.ts | 34 +- .../src/owner/mqttCredentialVerifier.test.ts | 17 + backend/src/owner/mqttCredentialVerifier.ts | 6 + backend/src/owner/ownerAccess.ts | 83 ++--- backend/src/tools/revokeOwnerSessions.ts | 36 +++ docs/decryption.md | 18 +- docs/runbook-owner-auth.md | 17 + frontend/src/pages/OwnerPortalPage.tsx | 18 +- .../src/pages/owner/ownerPortalModel.test.ts | 12 + frontend/src/pages/owner/ownerPortalModel.ts | 18 ++ scripts/newuser.sh | 9 +- scripts/test-newuser.sh | 294 ++++++++++++++---- 22 files changed, 648 insertions(+), 214 deletions(-) create mode 100644 .gitleaksignore create mode 100644 backend/src/api/routes/owner.test.ts create mode 100644 backend/src/mqtt/channelRegistry.test.ts create mode 100644 backend/src/owner/mqttCredentialVerifier.test.ts create mode 100644 backend/src/owner/mqttCredentialVerifier.ts create mode 100644 backend/src/tools/revokeOwnerSessions.ts create mode 100644 docs/runbook-owner-auth.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f06f91..cf52f14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: push: branches: ['**'] pull_request: + schedule: + - cron: '17 4 * * *' permissions: contents: read @@ -14,6 +16,7 @@ concurrency: jobs: backend: + if: github.event_name != 'schedule' name: Backend runs-on: ubuntu-latest services: @@ -62,6 +65,7 @@ jobs: - run: npm run build frontend: + if: github.event_name != 'schedule' name: Frontend runs-on: ubuntu-latest defaults: @@ -81,6 +85,7 @@ jobs: - run: npm run build workers-and-compose: + if: github.event_name != 'schedule' name: Workers and Compose runs-on: ubuntu-latest env: @@ -407,3 +412,7 @@ jobs: uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_VERSION: 8.30.0 + - name: Scan full repository history on push and pull request + if: github.event_name != 'schedule' + run: gitleaks git --redact --no-banner --log-opts=--all diff --git a/.gitleaks.toml b/.gitleaks.toml index e623993..4dd28ff 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -2,38 +2,3 @@ title = "UK Mesh gitleaks configuration" [extend] useDefault = true - -[[rules]] -id = "generic-api-key" - -[[rules.allowlists]] -description = "Documented MeshCore public-channel interoperability key" -paths = ['''^backend/backfill-transport-codes\.mjs$'''] -regexTarget = "line" -regexes = ['''(?i)const DEFAULT_PUBLIC_CHANNEL_KEY\s*=\s*'[0-9a-f]{32}'\s*;'''] -condition = "AND" - -[[rules.allowlists]] -description = "Reviewed public/community MeshCore channel secrets (channelRegistry.ts VALIDATED_CHANNELS) — each entry verified to decrypt real UK Mesh group text; classified intentionally public in the channel registry" -paths = ['''^backend/src/mqtt/channelRegistry\.ts$'''] -regexTarget = "line" -regexes = ['''\{ name: '[A-Za-z0-9_-]+',\s+secret: '[0-9a-f]{32}'\s*\},?'''] -condition = "AND" - -[[rules.allowlists]] -description = "Documented Public channel key in any file/historical commit (exact fingerprint)" -regexTarget = "secret" -regexes = ['''^8b3387e9c5cdea6ac9e5edbaa115cd72$'''] - -[[rules.allowlists]] -description = "Fake test fixtures (exact fingerprints)" -regexTarget = "secret" -regexes = [ - '''^0123456789abcdef0123456789abcdef$''', - '''^abcdef0123456789$''', -] - -[[rules.allowlists]] -description = "Deployed-commit SHA in live website manifests" -regexTarget = "secret" -regexes = ['''^bef003f072283fd5e3f14b29890f7610adf3fb9808e8c6e700bd48d3e7a8bc87$'''] diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..3bcbb2a --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,51 @@ +# Exact reviewed Gitleaks fingerprints (commit:path:rule:line). +# Public MeshCore channel material; keep private channel keys in MESHCORE_CHANNEL_SECRETS. +8b902dfb3ff08128556f22de2d239c458f782949:backend/src/mqtt/channelRegistry.ts:generic-api-key:38 +8b902dfb3ff08128556f22de2d239c458f782949:backend/src/mqtt/channelRegistry.ts:generic-api-key:42 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:30 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:31 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:32 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:33 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:34 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:35 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:36 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:37 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:38 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:39 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:42 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:43 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:46 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:47 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:48 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:49 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:50 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:51 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:52 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:53 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:54 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:55 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:56 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:57 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:58 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:59 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:60 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:61 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:62 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:63 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:65 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:66 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:67 +61fdcd9d11ef9a3aed93feb1d6cc4bf7c91e17d8:backend/src/mqtt/channelRegistry.ts:generic-api-key:68 +5db382e181591b9bf736647eb903c699223cfdc8:backend/backfill-transport-codes.mjs:generic-api-key:35 +be45a2e967ca51d909940e29ecfd531a4566d404:backend/backfill-transport-codes.mjs:generic-api-key:35 +288c66a0b6e1037c74dea4392ad0a910525333df:backend/backfill-transport-codes.mjs:generic-api-key:35 +cb713d6c1a469f634a5a11678a1b9a81fa6413a0:backend/src/mqtt/client.ts:generic-api-key:96 +# Test-only credential and token fixtures. +1ebc496965ff61f398851dde81c9da6910ceb7e0:backend/src/security/operatorAuth.test.ts:generic-api-key:5 +d278c5072891fcfc643e050c55fb6ace908fdaa3:backend/src/security/operatorAuth.test.ts:generic-api-key:5 +93cc3e1b385fe2034876caf4af843d040da47cf1:backend/src/spam/sanitize.test.ts:generic-api-key:50 +# Generated deployment manifests containing source revision identifiers. +cd9ee586364ed70c5298d36eba0bb92958725b2d:website-live-manifest.staging.json:generic-api-key:40 +cd9ee586364ed70c5298d36eba0bb92958725b2d:website-live-manifest.json:generic-api-key:40 +5210aba5e37b928db3744057beb78f2c17f92263:website-live-manifest.json:generic-api-key:38 +ac4ee2afecc16ed67fdc09cf70953dc85d3a4c5a:website-live-manifest.staging.json:generic-api-key:38 diff --git a/HANDOFF.md b/HANDOFF.md index da50077..434aa60 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,31 +1,49 @@ -# Mission B4 — small batch +# Mission C5c handoff -## Status +Status: complete locally +Branch: fix/ukmesh-auth-users +Push state: not pushed; coordinator relays these commits. -Complete on `fix/ukmesh-b4-small`, based on `origin/main` at `2fa80fa`. +## Commits -## Item disposition +- 9ce8633 — security: classify channel keys and baseline reviewed findings +- b50ecfa — security: revalidate MQTT auth and revoke owner sessions +- c1bc7c8 — test(ops): cover concurrent newuser grant updates +- a6b7a49 — fix(ops): preserve executable newuser test +- 771fc6f — fix(ops): cover partial newuser rollback failures +- 2be3357 — fix(owner): preserve password whitespace during login -- **#301 — fixed.** Added the retired-workload policy for `path-history`, re-exported `normalizeRetiredAnalysisState` from `runState.ts`, normalized retired state in readiness output, and retained the original failure under `retirement.historicalState`. New leases for the retired workload are rejected. Coverage exercises normalization, audit retention, active-run visibility, and rejection in both scopes. -- **#303 — fixed with measured rows (option a).** A `ResizeObserver` records rendered border-box heights by scope and packet hash. Prefix offsets drive both spacers and the visible range; unseen rows use 76px only as an initial estimate. Scroll anchoring preserves the first visible packet as measurements and packet order change. Row content remains unclipped. -- **#269 / #213 — triaged as expected denials.** Keep the `/raw` denials. The owner ACL renderer and backend topic parser accept the four supported suffixes; raw packet bytes are the `raw` JSON field on `/packets`, and the backend has no `/raw` topic consumer. Documented the log evidence and ruling in `docs/operations.md`; added a parser assertion that `/raw` remains unsupported. No ACL renderer change is needed. +## Changes -## Mission commits +- #50: Classified the 41 committed recovered/community-known channels as intentionally public; environment-supplied keys are classified confidential. Replaced broad Gitleaks regex exceptions with 47 exact reviewed fingerprints and added full-history scans on push/PR plus a scheduled scan. A synthetic unclassified key was still rejected by Gitleaks. +- #51: Removed positive MQTT credential caching. Existing credential-generation checks now fail closed on Redis read errors. Added owner-auth:revoke and dist/tools/revokeOwnerSessions.js for immediate session revocation after external MQTT password resets; the seven-day session lifetime was already in place. +- #52: Added a two-process regression test that adds a grant during discovery and verifies the first process preserves it after reacquiring the lock. The reread/merge logic was already present. +- #53: Marked credential, environment, and database mutations before issuing them so an interrupted/failed command reaches rollback. Added failures after credential, environment, database, and ACL changes. +- #58: Kept username normalization and preserved password whitespace through browser and backend login validation. Added frontend and API tests. -- #301: `d834ba8` — `fix(analysis): restore retired run-state policy (#301)` -- #303: `078e21b` — `fix(feed): measure UK feed virtual row heights (#303)` -- #269 / #213: `6c47b7f` — `docs(mqtt): document denied raw publish disposition (#269 #213)` +## Main overlap and audit note -## Tests and typechecks +Fetched origin/main before editing; it had no changes to these target files. This worktree remains based on the open #99 resolver branch. Existing session generations, seven-day expiry, newuser map reread/EXIT trap, and backend password preservation were already present in the base; this mission closed the remaining gaps and added regression tests. -- `cd backend && node --import tsx --test $(find src -name '*.test.ts' ! -name '*.integration.test.ts' -print)` — **328 passed, 0 failed**. -- `cd frontend && npm test` — **100 passed, 0 failed**. -- `cd frontend && node --import tsx --test src/hooks/measuredVirtualRows.test.ts` — **3 passed, 0 failed**. -- `cd backend && npm run typecheck` — **passed**. -- `cd frontend && ./node_modules/.bin/tsc --noEmit -p tsconfig.json` — **passed**. +The brief cites a historical broker credential. Gitleaks 8.30.0 scanned all 476 commits reachable from the fetched refs and found 47 historical hits, all reviewed as public channel values, test fixtures, or generated revision identifiers. It found no separate broker credential in those refs. The exact baseline does not allowlist an unidentified credential; Ben should still rotate the credential identified by the audit and confirm its source/history. -## Gated items and open question +## Tests and checks -No service/container actions, live database writes, ACL edits or reloads, pushes, or merges were performed. Database integration and browser E2E suites were not run. The supplied log review does not identify who owns the `meshmonitor-observer EXT` publisher; that does not change the ACL disposition. +- cd backend && node --import tsx --test src/mqtt/channelRegistry.test.ts src/owner/mqttCredentialVerifier.test.ts src/owner/ownerSession.test.ts src/api/routes/owner.test.ts — passed, 7/7. +- cd backend && ./node_modules/.bin/tsc --noEmit — passed. +- cd frontend && ./node_modules/.bin/tsx --test src/pages/owner/ownerPortalModel.test.ts — passed, 5/5. +- cd frontend && ./node_modules/.bin/tsc --noEmit — passed. +- cd scripts && NPM_CONFIG_CACHE="$PWD/.tmp/npm-cache" TMPDIR="$PWD/.tmp" npm test — passed (observer-key, infrastructure resolver, concurrent newuser, and rollback tests). +- gitleaks 8.30.0 git --config .gitleaks.toml --log-opts=--all --redact --no-banner — passed; 476 commits scanned, no unclassified findings. +- git diff --check — passed. -**Next:** coordinator/Hermes can relay the local commits. Trace the `meshmonitor-observer EXT` publisher only if stopping its unsupported `/raw` attempts is desired. +## Gated items for Ben + +- Rotate the MQTT broker credential identified in the audit. No broker/service/database operations were performed here. +- Confirm all 41 committed channel values are intentionally public. Move any confidential value to managed environment configuration before deployment. +- After deploying the backend tool, run node dist/tools/revokeOwnerSessions.js immediately after each MQTT password reset. Do not consider the reset complete unless the command succeeds. +- Coordinate merge/push and deployment. Nothing was pushed or merged from this host. + +## Workspace note + +Backend/frontend dependencies were installed from lockfiles into ignored node_modules. I initially ran those two npm ci commands without setting NPM_CONFIG_CACHE, so npm used its default host cache outside the worktree; subsequent script dependency installation used a worktree-local cache. No live checkout, .env, service/container, or database was changed, and no push was made. diff --git a/README.md b/README.md index f8e2555..17daa2d 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Copy `.env.example` to `.env` and fill in your values. All variables used by the | `ALLOWED_ORIGINS` | `http://localhost:3001,http://localhost:3002` | Comma-separated browser origins allowed for CORS and WebSocket | | `API_RATE_LIMIT_MAX` | `120` | Per-client public API requests/minute; raise only in an isolated load-test project | | `VITE_APP_HOSTNAME` | *(blank — always shows dashboard)* | If set, only this hostname serves the analytics dashboard; all others serve the public website layout | -| `MESHCORE_CHANNEL_SECRETS` | *(blank)* | Comma-separated channel secrets for decrypting GroupText packets. Format: `name:hex` or bare hex. The default MeshCore public channel key is always included. | +| `MESHCORE_CHANNEL_SECRETS` | *(blank)* | Confidential channel keys for decrypting GroupText packets, managed through environment configuration. Format: `name:hex` or bare hex. The explicitly public `PUBLIC_CHANNELS` defaults are always included. | | `OWNER_DATABASE_URL` | *(optional)* | Separate Postgres database URL for owner portal username → repeater mappings | | `OWNER_COOKIE_SECRET` | *(optional but recommended)* | Secret used to encrypt/sign the owner session cookie | | `OWNER_MQTT_USERNAME_MAP` | *(empty)* | Operator-managed owner grants in the format `user=nodeId1|nodeId2,...` | diff --git a/backend/package.json b/backend/package.json index c192fed..b1474a8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "stats:seed-chart-snapshot": "node dist/tools/seedStatsChartSnapshot.js", "db:lifecycle": "node dist/tools/dataLifecycle.js", "owner-auth:inventory": "node dist/tools/inventoryOwnerAuthorization.js", + "owner-auth:revoke": "node dist/tools/revokeOwnerSessions.js", "analysis:cleanup-orphans": "node dist/tools/cleanupOrphanedAnalysisRuns.js", "bench:decode": "tsx src/tools/benchmarkPacketDecode.ts", "load:realtime": "tsx src/tools/loadRealtime.ts", diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index f59be5b..e9fd13b 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -138,9 +138,8 @@ async function requireOwnerSession(req: Request, res: Response): Promise next(); +const TEST_COOKIE_SECRET = 'test-owner-cookie-secret-at-least-32-bytes'; + +async function startOwnerRoutes(options: { + currentGeneration?: () => Promise; + verifyCredentials?: (username: string, password: string) => Promise; +} = {}): Promise<{ baseUrl: string; close: () => Promise; restore: () => void }> { + const previousSecret = process.env['OWNER_COOKIE_SECRET']; + process.env['OWNER_COOKIE_SECRET'] = TEST_COOKIE_SECRET; + const router = Router(); + registerOwnerRoutes(router, { + ownerCookieName: 'meshcore_owner_session', + ownerLiveCacheTtlMs: 1_000, + ownerLiveCache: new Map(), + ownerDashboardCacheTtlMs: 1_000, + ownerLastHopCacheTtlMs: 1_000, + ownerSessionTtlMs: 60_000, + mqttUsernameMaxLen: 128, + mqttPasswordMaxLen: 128, + ownerLoginLimiter: passThroughLimiter as Parameters[1]['ownerLoginLimiter'], + hasControlChars: (value) => /[\u0000-\u001F\u007F]/.test(value), + verifyMqttCredentials: options.verifyCredentials ?? (async () => true), + resolveOwnerNodeIds: async () => [NODE_ID], + autoLinkOwnerNodeIds: async () => [NODE_ID], + buildOwnerDashboard: async (nodeIds) => ({ nodes: nodeIds.map((nodeId) => ({ node_id: nodeId })) }), + encryptOwnerSession, + isSecureRequest: () => false, + getOwnerSession: (req) => getOwnerSession(req, 'meshcore_owner_session'), + requireOwnerSession: async () => [NODE_ID], + getOwnerCredentialGeneration: options.currentGeneration ?? (async () => 2), + invalidateOwnerNodeIdCache: () => undefined, + query: async () => ({ rows: [] as T[] }), + }); + const app = express(); + app.use(express.json()); + app.use(router); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${port}`, + close: async () => { + server.close(); + await once(server, 'close'); + }, + restore: () => { + if (previousSecret === undefined) delete process.env['OWNER_COOKIE_SECRET']; + else process.env['OWNER_COOKIE_SECRET'] = previousSecret; + }, + }; +} + +test('owner session route rejects cookies from an older credential generation', async () => { + const ownerServer = await startOwnerRoutes(); + const token = encryptOwnerSession({ + v: 3, + mqttUsername: 'owner', + exp: Date.now() + 60_000, + gen: 1, + }); + + try { + const response = await fetch(`${ownerServer.baseUrl}/owner/session`, { + headers: { cookie: `meshcore_owner_session=${token}` }, + }); + + assert.equal(response.status, 401); + assert.equal((await response.json() as { error: string }).error, 'Credentials have been rotated — please log in again'); + } finally { + await ownerServer.close(); + ownerServer.restore(); + } +}); + +test('owner login verifies the exact password string, including surrounding whitespace', async () => { + let receivedCredentials: [string, string] | undefined; + const ownerServer = await startOwnerRoutes({ + verifyCredentials: async (username, password) => { + receivedCredentials = [username, password]; + return true; + }, + }); + + try { + const csrfResponse = await fetch(`${ownerServer.baseUrl}/owner/csrf`); + const { csrfToken } = await csrfResponse.json() as { csrfToken: string }; + const csrfCookie = csrfResponse.headers.get('set-cookie')?.split(';', 1)[0]; + assert.ok(csrfCookie); + + const response = await fetch(`${ownerServer.baseUrl}/owner/login`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: csrfCookie, + 'x-csrf-token': csrfToken, + }, + body: JSON.stringify({ mqttUsername: ' owner ', mqttPassword: ' password ' }), + }); + + assert.equal(response.status, 200); + assert.deepEqual(receivedCredentials, ['owner', ' password ']); + } finally { + await ownerServer.close(); + ownerServer.restore(); + } +}); diff --git a/backend/src/cache/policyRegistry.ts b/backend/src/cache/policyRegistry.ts index 7bfeaf8..a302dd2 100644 --- a/backend/src/cache/policyRegistry.ts +++ b/backend/src/cache/policyRegistry.ts @@ -123,12 +123,6 @@ export const CACHE_POLICY_REGISTRY: readonly CachePolicyRecord[] = Object.freeze scope: 'owner username', invalidation: 'authorization reconciliation + TTL', negativeCaching: 'empty verified grant list', singleFlight: 'ownerNodeIdInflight (cap 128)', }, - { - source: 'src/owner/ownerAccess.ts#authCache', - disposition: 'bounded-cache', maxEntries: 512, maxBytes: 2 << 20, ttlMs: 5 * 60_000, - scope: 'owner username + credential digest', invalidation: 'credential mismatch + TTL', - negativeCaching: 'positive results only', singleFlight: 'broker connection timeout', - }, { source: 'src/owner/ownerService.ts#ownerLastHopCache', disposition: 'bounded-cache', maxEntries: 1_024, maxBytes: 64 << 20, ttlMs: 60 * 60_000, diff --git a/backend/src/mqtt/channelRegistry.test.ts b/backend/src/mqtt/channelRegistry.test.ts new file mode 100644 index 0000000..a045fe3 --- /dev/null +++ b/backend/src/mqtt/channelRegistry.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildChannelEntries, PUBLIC_CHANNELS, VALIDATED_CHANNELS } from './channelRegistry.js'; + +test('committed channel material is explicitly classified as public', () => { + assert.strictEqual(VALIDATED_CHANNELS, PUBLIC_CHANNELS); + assert.ok(PUBLIC_CHANNELS.length > 0); + assert.ok(buildChannelEntries().every((entry) => entry.classification === 'public')); +}); + +test('environment-supplied channel keys are classified as confidential', () => { + const entry = buildChannelEntries(`private:${'a'.repeat(32)}`) + .find((channel) => channel.name === 'private'); + + assert.equal(entry?.classification, 'confidential'); +}); diff --git a/backend/src/mqtt/channelRegistry.ts b/backend/src/mqtt/channelRegistry.ts index 625ea42..806f692 100644 --- a/backend/src/mqtt/channelRegistry.ts +++ b/backend/src/mqtt/channelRegistry.ts @@ -1,11 +1,9 @@ /** - * Channel registry — the single source of truth for MeshCore channel secrets. + * Channel registry — explicitly public defaults plus confidential environment keys. * - * VALIDATED_CHANNELS are the default secrets baked into the service (all - * recovered/community-known channels, verified to decrypt real group text). - * MESHCORE_CHANNEL_SECRETS (env, 'name:hex' or bare 'hex', comma-separated) - * APPENDS extra channels on top — useful for community-shared secrets that - * shouldn't be committed. + * PUBLIC_CHANNELS contains only recovered/community-known material intentionally + * classified as public. MESHCORE_CHANNEL_SECRETS ('name:hex' or bare 'hex', + * comma-separated) adds confidential keys without committing their values. * * Also hosts the summary + channel-identification helpers so the ingest * pipeline and offline tools (e.g. backfillDecrypt) share one implementation. @@ -15,18 +13,21 @@ import type { GroupTextPayload } from '@michaelhart/meshcore-decoder'; import { decodePacketCompat } from './decodePacket.js'; import { BoundedTtlMap } from '../cache/boundedTtlMap.js'; +export type ChannelClassification = 'public' | 'confidential'; + export interface ChannelEntry { + classification: ChannelClassification; name: string; secret: string; keyStore: ReturnType; } /** - * Default channels — recovered via the meshcore wordlist/derivation audit - * (2026-08-06) and validated: each key decrypts real human-readable group - * text (sane epoch timestamp + printable text) on the UK Mesh network. + * Every committed value is intentionally public: recovered/community-known + * channels from the 2026-08-06 derivation audit, validated against real UK Mesh + * group text. Confidential channel keys belong in MESHCORE_CHANNEL_SECRETS. */ -export const VALIDATED_CHANNELS: ReadonlyArray<{ name: string; secret: string }> = [ +export const PUBLIC_CHANNELS: ReadonlyArray<{ name: string; secret: string }> = [ { name: 'Public', secret: '8b3387e9c5cdea6ac9e5edbaa115cd72' }, { name: 'test', secret: '9cd8fcf22a47333b591d96a2b848b73f' }, { name: 'bot', secret: 'eb50a1bcb3e4e5d7bf69a57c9dada211' }, @@ -70,9 +71,13 @@ export const VALIDATED_CHANNELS: ReadonlyArray<{ name: string; secret: string }> { name: 'uk', secret: '22b2eed34b5cc429ce1dc5e88635ff84' }, ]; +/** @deprecated Use PUBLIC_CHANNELS to make the source classification explicit. */ +export const VALIDATED_CHANNELS = PUBLIC_CHANNELS; + /** Build the channel entry list: committed defaults + env extras (dedup by secret). */ export function buildChannelEntries(envValue?: string): ChannelEntry[] { - const entries: ChannelEntry[] = VALIDATED_CHANNELS.map(({ name, secret }) => ({ + const entries: ChannelEntry[] = PUBLIC_CHANNELS.map(({ name, secret }) => ({ + classification: 'public', name, secret, keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets: [secret] }), @@ -84,7 +89,12 @@ export function buildChannelEntries(envValue?: string): ChannelEntry[] { const secret = colon > 0 ? raw.slice(colon + 1) : raw; if (seen.has(secret.toLowerCase())) continue; seen.add(secret.toLowerCase()); - entries.push({ name, secret, keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets: [secret] }) }); + entries.push({ + classification: 'confidential', + name, + secret, + keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets: [secret] }), + }); } return entries; } diff --git a/backend/src/owner/mqttCredentialVerifier.test.ts b/backend/src/owner/mqttCredentialVerifier.test.ts new file mode 100644 index 0000000..f62d8a0 --- /dev/null +++ b/backend/src/owner/mqttCredentialVerifier.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMqttCredentialVerifier } from './mqttCredentialVerifier.js'; + +test('rechecks unchanged MQTT credentials against the broker on every login', async () => { + let checks = 0; + const verify = createMqttCredentialVerifier(async (username, password) => { + checks++; + assert.equal(username, 'owner'); + assert.equal(password, 'unchanged-password'); + return checks === 1; + }); + + assert.equal(await verify('owner', 'unchanged-password'), true); + assert.equal(await verify('owner', 'unchanged-password'), false); + assert.equal(checks, 2); +}); diff --git a/backend/src/owner/mqttCredentialVerifier.ts b/backend/src/owner/mqttCredentialVerifier.ts new file mode 100644 index 0000000..71624c0 --- /dev/null +++ b/backend/src/owner/mqttCredentialVerifier.ts @@ -0,0 +1,6 @@ +export type BrokerCredentialCheck = (mqttUsername: string, mqttPassword: string) => Promise; + +/** Verify each login against the broker so cached success cannot outlive a password reset. */ +export function createMqttCredentialVerifier(check: BrokerCredentialCheck): BrokerCredentialCheck { + return (mqttUsername, mqttPassword) => check(mqttUsername, mqttPassword); +} diff --git a/backend/src/owner/ownerAccess.ts b/backend/src/owner/ownerAccess.ts index 9597851..4d47d26 100644 --- a/backend/src/owner/ownerAccess.ts +++ b/backend/src/owner/ownerAccess.ts @@ -1,5 +1,5 @@ import mqtt from 'mqtt'; -import { createHash, randomBytes } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import { Redis } from 'ioredis'; import { BoundedTtlMap } from '../cache/boundedTtlMap.js'; import { getOwnerNodeIdsForUsername } from '../db/ownerAuth.js'; @@ -9,6 +9,7 @@ import { getRedisConnectionOptions, getRedisUrl } from '../platform/config/redis import { reconcileOwnerAuthorization } from './ownerAclReconciler.js'; import { groupOwnerNodes, type OwnerDashboardRow } from './ownerDashboard.js'; import { parseOwnerGrantConfig } from './ownerGrantConfig.js'; +import { createMqttCredentialVerifier } from './mqttCredentialVerifier.js'; function normalizeNodeIds(nodeIds: string[]): string[] { return Array.from(new Set( @@ -93,50 +94,12 @@ export async function autoLinkOwnerNodeIds(mqttUsername: string): Promise({ - name: 'owner_auth', - maxEntries: Number(process.env['OWNER_AUTH_CACHE_MAX_ENTRIES'] ?? 512), - maxWeight: 2 * 1024 * 1024, - ttlMs: AUTH_CACHE_TTL_MS, -}); - -function credentialHash(mqttUsername: string, mqttPassword: string): string { - return createHash('sha256').update(`${mqttUsername}\u0000${mqttPassword}`).digest('hex'); -} - -export function verifyMqttCredentials(mqttUsername: string, mqttPassword: string): Promise { - const hash = credentialHash(mqttUsername, mqttPassword); - const cached = authCache.get(mqttUsername); - if (cached && cached.credentialHash === hash && Date.now() - cached.ts < AUTH_CACHE_TTL_MS) { - return Promise.resolve(true); - } - return verifyMqttCredentialsViaBroker(mqttUsername, mqttPassword).then((ok) => { - if (ok) { - authCache.set(mqttUsername, { credentialHash: hash, ts: Date.now() }); - } else if (cached && cached.credentialHash === hash) { - // Same credential that previously worked now rejected (revoked) — drop it - // AND bump the credential generation so every live owner session minted - // under the old password is invalidated immediately. - authCache.delete(mqttUsername); - void bumpOwnerCredentialGeneration(mqttUsername); - } - return ok; - }); -} +export const verifyMqttCredentials = createMqttCredentialVerifier(verifyMqttCredentialsViaBroker); // ---- Credential generation (BUG-010) ---- -// Owner sessions are stateless signed cookies with a 30-day TTL. To make -// password revocation effective immediately, every session records the -// credential generation that minted it. When the broker rejects a previously -// valid password (the only signal the backend has that a password changed), -// the generation is bumped in Redis and all older sessions stop validating. +// Owner sessions record the credential generation that minted them. The +// operator revocation command bumps this Redis value after a password reset, +// and every authenticated owner request compares it with the cookie generation. const OWNER_CRED_GEN_KEY = (username: string) => `owner:credential-gen:${username}`; const OWNER_CRED_GEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; @@ -154,27 +117,35 @@ function getCredGenRedis(): Redis { export async function getOwnerCredentialGeneration(mqttUsername: string): Promise { try { const value = await getCredGenRedis().get(OWNER_CRED_GEN_KEY(mqttUsername)); - const parsed = value === null ? NaN : Number(value); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + if (value === null) return 0; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error('INVALID_OWNER_CREDENTIAL_GENERATION'); + } + return parsed; } catch (error) { - console.error('[owner-credgen] read failed, treating as 0', error instanceof Error ? error.message : error); - return 0; + console.error('[owner-credgen] read failed', error instanceof Error ? error.message : error); + throw new Error('OWNER_CREDENTIAL_GENERATION_UNAVAILABLE', { cause: error }); } } /** * Bump the credential generation, invalidating every session minted under an - * older password. Failures are logged but non-fatal: the next login still - * succeeds and re-records the current generation. + * older password. Errors propagate so an operator reset cannot report success + * unless revocation was recorded. */ export async function bumpOwnerCredentialGeneration(mqttUsername: string): Promise { - try { - const redis = getCredGenRedis(); - await redis.incr(OWNER_CRED_GEN_KEY(mqttUsername)); - await redis.pexpire(OWNER_CRED_GEN_KEY(mqttUsername), OWNER_CRED_GEN_TTL_MS); - } catch (error) { - console.error('[owner-credgen] bump failed', error instanceof Error ? error.message : error); - } + const redis = getCredGenRedis(); + const key = OWNER_CRED_GEN_KEY(mqttUsername); + await redis.incr(key); + await redis.pexpire(key, OWNER_CRED_GEN_TTL_MS); +} + +export async function closeOwnerCredentialGenerationClient(): Promise { + if (!credGenRedis) return; + const client = credGenRedis; + credGenRedis = null; + await client.quit(); } function verifyMqttCredentialsViaBroker(mqttUsername: string, mqttPassword: string): Promise { diff --git a/backend/src/tools/revokeOwnerSessions.ts b/backend/src/tools/revokeOwnerSessions.ts new file mode 100644 index 0000000..29f446b --- /dev/null +++ b/backend/src/tools/revokeOwnerSessions.ts @@ -0,0 +1,36 @@ +import { + bumpOwnerCredentialGeneration, + closeOwnerCredentialGenerationClient, +} from '../owner/ownerAccess.js'; + +async function main(): Promise { + const mqttUsername = process.argv[2]?.trim() ?? ''; + if (process.argv.length !== 3 || !/^[A-Za-z0-9_.@-]{1,128}$/.test(mqttUsername)) { + console.error('Usage: node dist/tools/revokeOwnerSessions.js '); + process.exitCode = 2; + return; + } + + try { + await bumpOwnerCredentialGeneration(mqttUsername); + console.log(`Revoked owner sessions for ${mqttUsername}`); + } catch (error) { + console.error( + 'Could not revoke owner sessions:', + error instanceof Error ? error.message : String(error), + ); + process.exitCode = 1; + } finally { + try { + await closeOwnerCredentialGenerationClient(); + } catch (error) { + console.error( + 'Could not close owner credential generation connection:', + error instanceof Error ? error.message : String(error), + ); + process.exitCode = 1; + } + } +} + +void main(); diff --git a/docs/decryption.md b/docs/decryption.md index daff654..e6830c2 100644 --- a/docs/decryption.md +++ b/docs/decryption.md @@ -6,16 +6,20 @@ how historical traffic was retroactively decrypted. ## Key store - `backend/src/mqtt/channelRegistry.ts` is the single source of truth: - - `VALIDATED_CHANNELS` — default secrets baked into the image (39 entries - incl. Public; recovered 2026-08-06 and validated to decrypt real - human-readable group text). + - `PUBLIC_CHANNELS` — 41 recovered/community-known values intentionally + classified as public and baked into the image. Each was validated against + real human-readable UK Mesh group text. - `buildCombinedKeyStore()` — merges baked defaults with - `MESHCORE_CHANNEL_SECRETS` (env, comma-separated `name:hex` or bare hex; - dedupes by secret) for secrets that shouldn't be committed. + `MESHCORE_CHANNEL_SECRETS` (managed environment configuration, with + comma-separated `name:hex` or bare hex entries; dedupes by secret). + Environment-supplied entries are classified as confidential; never commit + their values or copy them into `PUBLIC_CHANNELS`. - `buildSummary()` / `identifyChannel()` — shared by ingest and offline tools. -- ⚠️ The repo is **public**: keys in `channelRegistry.ts` are public. Keep - non-derivable/community keys in the env var only. +- The repo is **public**. `PUBLIC_CHANNELS` is the explicit source classification; + the scanner baseline in `.gitleaksignore` records exact historical + commit/path/rule/line fingerprints for reviewed public values, test fixtures, + and generated revision identifiers. New findings remain blocking by default. - Reading env at startup only — after a change: `docker compose -f docker-compose.yml -f docker-compose.live.yml up -d --no-deps --force-recreate backend`. diff --git a/docs/runbook-owner-auth.md b/docs/runbook-owner-auth.md new file mode 100644 index 0000000..5c6f3d7 --- /dev/null +++ b/docs/runbook-owner-auth.md @@ -0,0 +1,17 @@ +# Owner credential rotation + +Owner cookies include the Redis credential generation that was current at login. +The owner session route and protected owner routes compare that generation on +each request. Cookies expire after seven days. + +After changing a Mosquitto password, revoke existing owner sessions immediately +by running the backend tool with the affected MQTT username: + +```sh +docker exec meshcore-analytics-backend-1 \ + node dist/tools/revokeOwnerSessions.js +``` + +The command fails if Redis cannot record the new generation. Confirm success +before treating password rotation as complete. Do not pass the password to this +command or record it in logs. diff --git a/frontend/src/pages/OwnerPortalPage.tsx b/frontend/src/pages/OwnerPortalPage.tsx index c5f777c..03161c2 100644 --- a/frontend/src/pages/OwnerPortalPage.tsx +++ b/frontend/src/pages/OwnerPortalPage.tsx @@ -22,6 +22,8 @@ import { lastHopSeriesCache, linkBadge, nodeRoleLabel, + ownerLoginCredentials, + ownerLoginValidationError, publishOwnerSession, type LastHopStrengthPoint, type MappedPeer, @@ -128,13 +130,14 @@ export const OwnerPortalPage: React.FC = () => { const handleLogin = (event: FormEvent) => { event.preventDefault(); - if (!mqttUsername.trim() || !mqttPassword) { - setError('Enter your MQTT username and password.'); + const validationError = ownerLoginValidationError(mqttUsername, mqttPassword); + if (validationError) { + setError(validationError); return; } setSubmitting(true); setError(null); - const loginUsername = mqttUsername.trim(); + const credentials = ownerLoginCredentials(mqttUsername, mqttPassword); fetchOwnerCsrfToken() .then((csrfToken) => fetchJson('/api/owner/login', { method: 'POST', @@ -142,10 +145,7 @@ export const OwnerPortalPage: React.FC = () => { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken, }, - body: JSON.stringify({ - mqttUsername: loginUsername, - mqttPassword, - }), + body: JSON.stringify(credentials), }, { timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024, @@ -155,8 +155,8 @@ export const OwnerPortalPage: React.FC = () => { .then((json) => { lastHopSeriesCache.clear(); setDashboard(json.dashboard); - setOwnerSessionKey(json.mqttUsername ?? loginUsername); - publishOwnerSession(json.mqttUsername ?? loginUsername); + setOwnerSessionKey(json.mqttUsername ?? credentials.mqttUsername); + publishOwnerSession(json.mqttUsername ?? credentials.mqttUsername); if (json.dashboard.nodes[0]?.node_id) { setSelectedNodeId(json.dashboard.nodes[0].node_id); } diff --git a/frontend/src/pages/owner/ownerPortalModel.test.ts b/frontend/src/pages/owner/ownerPortalModel.test.ts index 9711e48..bdbeaf2 100644 --- a/frontend/src/pages/owner/ownerPortalModel.test.ts +++ b/frontend/src/pages/owner/ownerPortalModel.test.ts @@ -9,8 +9,20 @@ import { isOwnerSessionResponse, isValidMapCoord, nodeRoleLabel, + ownerLoginCredentials, + ownerLoginValidationError, } from './ownerPortalModel.js'; +test('owner login trims only the username and preserves password whitespace', () => { + assert.equal(ownerLoginValidationError(' owner ', ' password '), null); + assert.deepEqual(ownerLoginCredentials(' owner ', ' password '), { + mqttUsername: 'owner', + mqttPassword: ' password ', + }); + assert.match(ownerLoginValidationError('owner', '\tpassword') ?? '', /invalid characters/); + assert.match(ownerLoginValidationError('owner', 'x'.repeat(129)) ?? '', /too long/); +}); + test('owner map coordinate policy rejects placeholders and invalid coordinates', () => { assert.equal(isValidMapCoord(51.5, -0.1), true); assert.equal(isValidMapCoord(0, 0), false); diff --git a/frontend/src/pages/owner/ownerPortalModel.ts b/frontend/src/pages/owner/ownerPortalModel.ts index 64b02c6..f9ce3b3 100644 --- a/frontend/src/pages/owner/ownerPortalModel.ts +++ b/frontend/src/pages/owner/ownerPortalModel.ts @@ -21,6 +21,24 @@ export function nodeRoleLabel(role: number | null): string { return 'Repeater'; } +export type OwnerLoginCredentials = { mqttUsername: string; mqttPassword: string }; + +export function ownerLoginValidationError(mqttUsername: string, mqttPassword: string): string | null { + const normalizedUsername = mqttUsername.trim(); + if (!normalizedUsername || !mqttPassword) return 'Enter your MQTT username and password.'; + if (normalizedUsername.length > 128 || mqttPassword.length > 128) { + return 'MQTT username or password is too long.'; + } + if (/[\u0000-\u001F\u007F]/.test(normalizedUsername) || /[\u0000-\u001F\u007F]/.test(mqttPassword)) { + return 'MQTT username or password contains invalid characters.'; + } + return null; +} + +export function ownerLoginCredentials(mqttUsername: string, mqttPassword: string): OwnerLoginCredentials { + return { mqttUsername: mqttUsername.trim(), mqttPassword }; +} + export type OwnerDashboard = { nodes: OwnerNode[]; }; diff --git a/scripts/newuser.sh b/scripts/newuser.sh index 7009172..140ef23 100755 --- a/scripts/newuser.sh +++ b/scripts/newuser.sh @@ -617,9 +617,12 @@ else password="$(openssl rand -hex 24)" log 'creating persistent Mosquitto credential' + # Mark the transaction as owning this username before invoking the mutating + # command. If it changes the passwd file and then exits unsuccessfully, EXIT + # rollback must still remove the partial credential. + credential_created=1 docker exec "$MOSQUITTO_CONTAINER" \ mosquitto_passwd -b /mosquitto/config/passwd "$username" "$password" - credential_created=1 # mosquitto only re-reads password_file on SIGHUP; the reconciler reloads # every ~60s but a device may connect before then, so reload immediately. docker exec "$MOSQUITTO_CONTAINER" sh -c 'kill -HUP 1' >/dev/null 2>&1 \ @@ -715,10 +718,11 @@ else new_owner_map=$new_entry fi log 'adding deduplicated owner grant to OWNER_MQTT_USERNAME_MAP' -replace_owner_map "$new_owner_map" env_changed=1 +replace_owner_map "$new_owner_map" log 'inserting verified owner account and node grant rows' +db_changed=1 docker exec -i "$TIMESCALEDB_CONTAINER" \ psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$OWNER_DATABASE" \ -v "mqtt_username=${username}" -v "node_ids=${key_csv}" >/dev/null <<'SQL' @@ -759,7 +763,6 @@ SET verification_method = 'operator-config', updated_at = NOW(); COMMIT; SQL -db_changed=1 log 'recreating only the backend to reconcile owner grants and reload the ACL' apply_backend_config diff --git a/scripts/test-newuser.sh b/scripts/test-newuser.sh index 8b5e51f..60484da 100755 --- a/scripts/test-newuser.sh +++ b/scripts/test-newuser.sh @@ -2,86 +2,256 @@ set -Eeuo pipefail -readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly NODE_A='A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1' readonly NODE_B='B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2' -readonly NODE_C='C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3' +readonly NODE_C="$(printf 'C3%.0s' {1..32})" test_root="$(mktemp -d)" -trap 'rm -rf -- "$test_root"' EXIT -mkdir -p "$test_root/bin" "$test_root/repo" "$test_root/infra" -touch "$test_root/infra/docker-compose.yml" -printf 'OWNER_MQTT_USERNAME_MAP=alice=%s,bob=%s\n' "$NODE_A" "$NODE_C" \ - >"$test_root/repo/.env" +cleanup() { + local status=$? + if (( status == 0 )); then + rm -rf -- "$test_root" + else + printf 'newuser test artifacts retained at %s\n' "$test_root" >&2 + fi +} +trap cleanup EXIT +mkdir -p "$test_root/bin" cat >"$test_root/bin/docker" <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail -printf 'ARGS %s\n' "$*" >>"$FAKE_DOCKER_LOG" +if [[ ${1:-} == inspect ]]; then + if [[ $* == *State.Running* ]]; then + printf 'true\n' + elif [[ $* == *State.Status* ]]; then + printf 'running\n' + else + printf 'healthy\n' + fi + exit 0 +fi -case ${1:-} in - inspect) - if [[ $* == *State.Running* ]]; then - printf 'true\n' +if [[ ${1:-} == compose ]]; then + case "$*" in + *'ps -q mosquitto') printf 'infra-mosquitto-id\n' ;; + *'ps -q timescaledb') printf 'infra-timescaledb-id\n' ;; + *' up -d --no-deps backend') printf 'COMPOSE_BACKEND\n' >>"$FAKE_DOCKER_LOG" ;; + *) printf 'unexpected docker compose invocation: %s\n' "$*" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ ${1:-} == logs ]]; then + if [[ -n ${FAKE_DISCOVERY_WAIT_MARKER:-} ]]; then + : >"$FAKE_DISCOVERY_WAIT_MARKER" + fi + if [[ -n ${FAKE_DISCOVERY_RELEASE:-} && -e $FAKE_DISCOVERY_RELEASE ]]; then + printf "New client connected from 127.0.0.1:1234 as meshcore_%s_1 (p2, c1, k60, u'%s').\n" \ + "$FAKE_DISCOVERY_NODE" "$FAKE_DISCOVERY_USERNAME" + fi + exit 0 +fi + +[[ ${1:-} == exec ]] || { printf 'unexpected docker invocation: %s\n' "$*" >&2; exit 1; } + +# Never put the generated broker password into test logs. +if [[ $* == *'NEWUSER_MQTT_PASSWORD='* ]]; then + printf 'VERIFY_BROKER_LOGIN\n' >>"$FAKE_DOCKER_LOG" + exit 0 +fi + +if [[ $* == *'mosquitto_passwd -b '* ]]; then + username="${!#}" + printf 'CREDENTIAL_CREATE %s\n' "$username" >>"$FAKE_DOCKER_LOG" + : >"$FAKE_CREDENTIAL_FILE" + [[ ${FAKE_FAIL_STAGE:-} != credential ]] || exit 1 + exit 0 +fi + +if [[ $* == *'mosquitto_passwd -D '* ]]; then + username="${!#}" + printf 'CREDENTIAL_DELETE %s\n' "$username" >>"$FAKE_DOCKER_LOG" + rm -f -- "$FAKE_CREDENTIAL_FILE" + exit 0 +fi + +if [[ $* == *'while IFS=: read'* ]]; then + username="${!#}" + if [[ ",${FAKE_EXISTING_USERS:-}," == *",${username},"* ]]; then + exit 0 + fi + exit 1 +fi + +if [[ $* == *'/mosquitto/config/acl'* ]]; then + if [[ $* == *'node_id='* ]]; then + printf 'ACL_READBACK\n' >>"$FAKE_DOCKER_LOG" + [[ ${FAKE_FAIL_STAGE:-} != acl ]] + exit $? + fi + printf 'ACL_PRECHECK\n' >>"$FAKE_DOCKER_LOG" + exit 1 +fi + +sql='' +if [[ " $* " == *' -i '* ]]; then + sql="$(cat)" + printf 'SQL %s\n' "$sql" >>"$FAKE_DOCKER_LOG" +fi + +mqtt_username='' +for argument in "$@"; do + case $argument in + mqtt_username=*) mqtt_username=${argument#*=} ;; + esac +done + +if [[ $* == *' psql '* ]]; then + if [[ $sql == *'SELECT COALESCE('* ]]; then + if [[ ",${FAKE_EXISTING_DB_USERS:-}," == *",${mqtt_username},"* ]]; then + printf 'active\n' else - printf 'healthy\n' + printf 'missing\n' fi - ;; - compose) - case "$*" in - *'ps -q mosquitto') printf 'infra-mosquitto-id\n' ;; - *'ps -q timescaledb') printf 'infra-timescaledb-id\n' ;; - *' up -d --no-deps backend') ;; - *) printf 'unexpected docker compose invocation: %s\n' "$*" >&2; exit 1 ;; - esac - ;; - exec) - sql='' - if [[ " $* " == *' -i '* ]]; then - sql="$(cat)" - printf 'STDIN %s\n' "$sql" >>"$FAKE_DOCKER_LOG" - fi - if [[ $* == *' psql '* ]]; then - if [[ $sql == *'SELECT COALESCE('* ]]; then - printf 'active\n' - elif [[ $sql == *'JOIN owner_accounts'* ]]; then - printf '1\n' - elif [[ $sql == *'node_identity_nodes'* ]]; then - printf '1\n' - fi - fi - ;; - *) - printf 'unexpected docker invocation: %s\n' "$*" >&2 - exit 1 - ;; -esac + elif [[ $sql == *'SELECT COUNT(*)'*'JOIN owner_accounts'* ]]; then + if [[ ${FAKE_FAIL_STAGE:-} == database-readback ]]; then printf '0\n'; else printf '1\n'; fi + elif [[ $sql == *'SELECT COUNT(*) FROM owner_account_nodes'* ]]; then + printf '0\n' + elif [[ $sql == *'INSERT INTO owner_account_nodes'* ]]; then + printf 'DB_WRITE %s\n' "$mqtt_username" >>"$FAKE_DOCKER_LOG" + [[ ${FAKE_FAIL_STAGE:-} != environment ]] || exit 1 + : >"$FAKE_DB_STATE_FILE" + [[ ${FAKE_FAIL_STAGE:-} != db ]] || exit 1 + elif [[ $sql == *'node_identity_nodes'* ]]; then + printf '0\n' + elif [[ $sql == *'DELETE FROM owner_accounts'* || $sql == *'DELETE FROM owner_account_nodes'* ]]; then + printf 'DB_ROLLBACK %s\n' "$mqtt_username" >>"$FAKE_DOCKER_LOG" + rm -f -- "$FAKE_DB_STATE_FILE" + fi + exit 0 +fi + +# SIGHUP, health probes, and all other fake exec operations succeed. +exit 0 EOF chmod +x "$test_root/bin/docker" -export FAKE_DOCKER_LOG="$test_root/docker.log" -output="$test_root/output.log" -PATH="$test_root/bin:$PATH" \ -NEWUSER_REPO_DIR="$test_root/repo" \ -MESHCORE_INFRA_PROJECT_DIR="$test_root/infra" \ -TMPDIR="$test_root" \ - bash "$SCRIPT_DIR/newuser.sh" --link alice "$NODE_B" >"$output" 2>&1 +init_sandbox() { + local name=$1 + local initial_map=$2 + SANDBOX="$test_root/$name" + mkdir -p "$SANDBOX/repo" "$SANDBOX/infra" + touch "$SANDBOX/infra/docker-compose.yml" + printf 'OWNER_MQTT_USERNAME_MAP=%s\n' "$initial_map" >"$SANDBOX/repo/.env" + : >"$SANDBOX/docker.log" + export FAKE_DOCKER_LOG="$SANDBOX/docker.log" + export FAKE_CREDENTIAL_FILE="$SANDBOX/credential-state" + export FAKE_DB_STATE_FILE="$SANDBOX/db-state" +} +run_newuser() { + local sandbox=$1 + local output=$2 + local existing_users=$3 + local existing_db_users=$4 + local fail_stage=$5 + shift 5 + PATH="$test_root/bin:$PATH" \ + NEWUSER_REPO_DIR="$sandbox/repo" \ + MESHCORE_INFRA_PROJECT_DIR="$sandbox/infra" \ + TMPDIR="$sandbox" \ + NEWUSER_DISCOVERY_POLL_SECONDS=0.05 \ + FAKE_EXISTING_USERS="$existing_users" \ + FAKE_EXISTING_DB_USERS="$existing_db_users" \ + FAKE_FAIL_STAGE="$fail_stage" \ + FAKE_DISCOVERY_WAIT_MARKER="${sandbox}/discovery-waiting" \ + FAKE_DISCOVERY_RELEASE="${sandbox}/discovery-release" \ + FAKE_DISCOVERY_NODE="$NODE_B" \ + FAKE_DISCOVERY_USERNAME=charlie \ + bash "$SCRIPT_DIR/newuser.sh" "$@" >"$output" 2>&1 +} + +assert_file_contains() { + local file=$1 + local text=$2 + grep -Fq -- "$text" "$file" \ + || { printf 'expected %s to contain: %s\n' "$file" "$text" >&2; exit 1; } +} + +# Linking a node preserves the current map and existing broker password. +init_sandbox link "alice=${NODE_A},bob=${NODE_C}" +run_newuser "$SANDBOX" "$SANDBOX/output.log" 'alice' 'alice' '' --link alice "$NODE_B" expected_map="OWNER_MQTT_USERNAME_MAP=alice=${NODE_A},bob=${NODE_C},alice=${NODE_B}" -[[ $(<"$test_root/repo/.env") == "$expected_map" ]] \ - || { printf 'unexpected owner map: %s\n' "$(<"$test_root/repo/.env")" >&2; exit 1; } -grep -Fq 'Password: unchanged (existing credential)' "$output" -grep -Fq 'INSERT INTO owner_account_nodes' "$FAKE_DOCKER_LOG" -grep -Fq 'compose -f docker-compose.yml -f docker-compose.live.yml up -d --no-deps backend' \ - "$FAKE_DOCKER_LOG" -grep -Fq "compose --project-directory $test_root/infra --project-name meshcore-infra -f $test_root/infra/docker-compose.yml ps -q mosquitto" \ - "$FAKE_DOCKER_LOG" -grep -Fq "compose --project-directory $test_root/infra --project-name meshcore-infra -f $test_root/infra/docker-compose.yml ps -q timescaledb" \ - "$FAKE_DOCKER_LOG" -if grep -Fq 'mosquitto_passwd -b' "$FAKE_DOCKER_LOG"; then +[[ $(<"$SANDBOX/repo/.env") == "$expected_map" ]] \ + || { printf 'unexpected owner map: %s\n' "$(<"$SANDBOX/repo/.env")" >&2; exit 1; } +assert_file_contains "$SANDBOX/output.log" 'Password: unchanged (existing credential)' +assert_file_contains "$SANDBOX/docker.log" 'INSERT INTO owner_account_nodes' +assert_file_contains "$SANDBOX/docker.log" 'COMPOSE_BACKEND' +if grep -Fq 'CREDENTIAL_CREATE alice' "$SANDBOX/docker.log"; then printf 'link flow unexpectedly changed the existing credential\n' >&2 exit 1 fi -printf 'newuser multi-node link test passed\n' +# A second process adds a grant while discovery has released the lock. The +# discovery run must merge into the map it rereads after reacquiring the lock. +init_sandbox concurrent "alice=${NODE_A}" +PATH="$test_root/bin:$PATH" \ +NEWUSER_REPO_DIR="$SANDBOX/repo" \ +MESHCORE_INFRA_PROJECT_DIR="$SANDBOX/infra" \ +TMPDIR="$SANDBOX" \ +NEWUSER_DISCOVERY_POLL_SECONDS=0.05 \ +FAKE_DOCKER_LOG="$SANDBOX/docker.log" \ +FAKE_CREDENTIAL_FILE="$SANDBOX/credential-state" \ +FAKE_DB_STATE_FILE="$SANDBOX/db-state" \ +FAKE_EXISTING_USERS='alice,charlie' \ +FAKE_EXISTING_DB_USERS=alice \ +FAKE_DISCOVERY_WAIT_MARKER="$SANDBOX/discovery-waiting" \ +FAKE_DISCOVERY_RELEASE="$SANDBOX/discovery-release" \ +FAKE_DISCOVERY_NODE="$NODE_B" \ +FAKE_DISCOVERY_USERNAME=charlie \ + bash "$SCRIPT_DIR/newuser.sh" --watch --timeout 10 charlie >"$SANDBOX/watcher.log" 2>&1 & +watcher_pid=$! +for _ in {1..100}; do + [[ -e $SANDBOX/discovery-waiting ]] && break + sleep 0.02 +done +[[ -e $SANDBOX/discovery-waiting ]] \ + || { kill "$watcher_pid" 2>/dev/null || true; printf 'watcher did not enter discovery\n' >&2; exit 1; } +run_newuser "$SANDBOX" "$SANDBOX/second-run.log" 'alice,charlie' 'alice' '' bob "$NODE_C" +: >"$SANDBOX/discovery-release" +wait "$watcher_pid" +expected_map="OWNER_MQTT_USERNAME_MAP=alice=${NODE_A},bob=${NODE_C},charlie=${NODE_B}" +[[ $(<"$SANDBOX/repo/.env") == "$expected_map" ]] \ + || { printf 'concurrent owner map lost a grant: %s\n' "$(<"$SANDBOX/repo/.env")" >&2; exit 1; } + +# Inject failures after each mutation class and check that EXIT rollback is +# idempotent and restores the mocked broker/config/database state. +for stage in credential environment db acl; do + init_sandbox "rollback-${stage}" "alice=${NODE_A}" + if run_newuser "$SANDBOX" "$SANDBOX/output.log" alice alice "$stage" dave "$NODE_B"; then + printf 'expected injected %s failure\n' "$stage" >&2 + exit 1 + fi + [[ $(<"$SANDBOX/repo/.env") == "OWNER_MQTT_USERNAME_MAP=alice=${NODE_A}" ]] \ + || { printf '%s failure did not restore the environment map\n' "$stage" >&2; exit 1; } + [[ ! -e $SANDBOX/credential-state ]] \ + || { printf '%s failure left a broker credential behind\n' "$stage" >&2; exit 1; } + [[ ! -e $SANDBOX/db-state ]] \ + || { printf '%s failure left owner database state behind\n' "$stage" >&2; exit 1; } + if [[ $stage == credential ]]; then + assert_file_contains "$SANDBOX/docker.log" 'CREDENTIAL_DELETE dave' + fi + if [[ $stage == db || $stage == acl ]]; then + assert_file_contains "$SANDBOX/docker.log" 'DB_ROLLBACK dave' + fi + if [[ $stage == acl ]]; then + compose_count="$(grep -c '^COMPOSE_BACKEND$' "$SANDBOX/docker.log")" + [[ $compose_count -ge 2 ]] \ + || { printf 'ACL failure did not reapply restored backend config\n' >&2; exit 1; } + fi +done + +printf 'newuser concurrency and rollback regression tests passed\n'