diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dee2bb4..014d194 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/README.md b/README.md index aa5dfac..0048f7c 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/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/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`.