diff --git a/.env.example b/.env.example index 759234f..1ca1691 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,10 @@ HOPREACH_MEMORY_LIMIT=8g # The default MeshCore public channel key is always included automatically. # Example: MESHCORE_CHANNEL_SECRETS=mychannel:9cd4e1f2a3b4c5d6e7f8a9b0c1d2e3f4,aabbccddeeff00112233445566778899 MESHCORE_CHANNEL_SECRETS= +# Extra channel names (comma-separated) whose plaintext the public API may +# serve. Committed PUBLIC_CHANNELS are always public; env-supplied secrets are +# confidential unless listed here. Empty = deny all env-supplied channels. +PUBLIC_DECRYPT_CHANNELS= # ── Health check app ────────────────────────────────────────────────────────── # Optional MeshCore health-check web app for healthcheck.ukmesh.com. @@ -176,10 +180,10 @@ HEALTHCHECK_TURNSTILE_SECRET_KEY= # Example: # OWNER_MQTT_USERNAME_MAP=node1=10cfab...713c95,node-ukmesh=20ed75...fe2ac OWNER_MQTT_USERNAME_MAP=hermes-test=A2FF345C45EDDCCB453C9F6B1E9973296932CD4C32EC106541D972BFBF4A4FC5 -# Start in shadow/shadow, review `owner-auth:inventory`, then switch both to -# enforce/apply only after the inventory has no ambiguous users or missing grants. -OWNER_AUTHORIZATION_MODE=shadow -OWNER_ACL_MODE=shadow +# Owner authorization and ACL reconciliation fail closed by default. Shadow is +# an explicit opt-in for migration/CI stacks without an owner-auth database. +OWNER_AUTHORIZATION_MODE=enforce +OWNER_ACL_MODE=apply OWNER_ACL_UNMANAGED_USERS=backend,test,test2 # Explicit exception list for intentionally revoked/disabled accounts that # should retain an empty broker stanza during apply cutover. diff --git a/backend/src/api/routes/misc.ts b/backend/src/api/routes/misc.ts index d9e8fe0..dd8f990 100644 --- a/backend/src/api/routes/misc.ts +++ b/backend/src/api/routes/misc.ts @@ -5,6 +5,7 @@ import { normalizeObserverQuery } from '../utils/observer.js'; import { expandResolverScope } from '../../networks.js'; import { networkFilters } from '../utils/networkFilters.js'; import { getPublicRuntimeFeatureConfig } from '../../features.js'; +import { isPublicChannelName } from '../../mqtt/channelRegistry.js'; import { parseBoolean, parseBoundedInteger, @@ -138,6 +139,10 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void { min: 1, max: 50, }); + if (!isPublicChannelName(channel)) { + res.status(404).json({ error: 'Channel is not public' }); + return; + } try { const network = resolvePublicNetworkScope(req.query['network'], req.headers); const observer = normalizeObserverQuery(req.query['observer']); diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 5463bf0..8dd3bc5 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -27,7 +27,7 @@ import { reactivateHistoricPathNodes, type HistoricPathNode, } from '../repositories/pathEvidence.js'; -import { channelHashesForName } from '../mqtt/channelRegistry.js'; +import { channelHashesForName, isPublicChannelName, sanitizePublicPacketPayload } from '../mqtt/channelRegistry.js'; import { buildPacketBatchInput, type InsertPacketInput } from './packetInput.js'; import { getRamIngestMode } from '../ram/protocol.js'; @@ -1033,6 +1033,7 @@ export async function getChannelMessageHistory( observer?: string, ) { const normalizedChannel = channel.trim().toLowerCase(); + if (!isPublicChannelName(normalizedChannel)) return []; const channelHashes = channelHashesForName(normalizedChannel); const scope = buildScopePlaceholders(6, network, observer); // A historical packet may retain the ingest-time "[encrypted]" marker in @@ -1231,7 +1232,7 @@ export async function getPacketDetail(hash: string, network = 'ukmesh') { hopCount: row.hop_count as number | null, rssi: row.rssi as number | null, snr: row.snr as number | null, - payload: row.payload as Record | null, + payload: sanitizePublicPacketPayload(row.payload as Record | null), pathHashes: row.path_hashes as string[] | null, pathHashSizeBytes: row.path_hash_size_bytes as number | null, rawHex: row.raw_hex as string | null, diff --git a/backend/src/mqtt/channelRegistry.test.ts b/backend/src/mqtt/channelRegistry.test.ts index a045fe3..4dbc4e4 100644 --- a/backend/src/mqtt/channelRegistry.test.ts +++ b/backend/src/mqtt/channelRegistry.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildChannelEntries, PUBLIC_CHANNELS, VALIDATED_CHANNELS } from './channelRegistry.js'; +import { buildChannelEntries, channelHashesForName, isPublicChannelName, PUBLIC_CHANNELS, sanitizePublicPacketPayload, VALIDATED_CHANNELS } from './channelRegistry.js'; test('committed channel material is explicitly classified as public', () => { assert.strictEqual(VALIDATED_CHANNELS, PUBLIC_CHANNELS); @@ -14,3 +14,27 @@ test('environment-supplied channel keys are classified as confidential', () => { assert.equal(entry?.classification, 'confidential'); }); + +test('confidential channels are not public unless explicitly allowlisted', () => { + assert.equal(isPublicChannelName('private', ''), false); + assert.equal(isPublicChannelName('private', 'private'), true); + assert.equal(isPublicChannelName('Public', ''), true); +}); + +test('public packet payloads keep plaintext only for public channels', () => { + const secrets = `private:${'a'.repeat(32)}`; + const channelHash = channelHashesForName('private', secrets)[0]!; + const payload = { + other: 1, + _summary: '[private] hello', + decrypted: { channelHash, message: 'hello' }, + }; + + const stripped = sanitizePublicPacketPayload(payload, secrets, ''); + assert.equal(stripped?.['decrypted'], undefined); + assert.equal(stripped?.['_summary'], undefined); + assert.equal(stripped?.['other'], 1); + + const kept = sanitizePublicPacketPayload(payload, secrets, 'private'); + assert.deepEqual(kept?.['decrypted'], { channelHash, message: 'hello' }); +}); diff --git a/backend/src/mqtt/channelRegistry.ts b/backend/src/mqtt/channelRegistry.ts index 806f692..3f19d34 100644 --- a/backend/src/mqtt/channelRegistry.ts +++ b/backend/src/mqtt/channelRegistry.ts @@ -128,6 +128,68 @@ export function buildCombinedKeyStore(entries: ChannelEntry[]) { return MeshCoreDecoder.createKeyStore({ channelSecrets: entries.map((e) => e.secret) }); } +/** + * Channel names whose plaintext may be published by the public API. + * + * Committed PUBLIC_CHANNELS are public by definition. Environment-supplied + * secrets are confidential unless their name is explicitly added to + * PUBLIC_DECRYPT_CHANNELS (comma-separated, default empty = deny). + */ +export function isPublicChannelName( + name: string, + allowlist = process.env['PUBLIC_DECRYPT_CHANNELS'], +): boolean { + const normalized = name.trim().toLowerCase(); + if (PUBLIC_CHANNELS.some((channel) => channel.name.toLowerCase() === normalized)) return true; + return String(allowlist ?? '') + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + .includes(normalized); +} + +/** Wire-level hashes whose plaintext the public API may return. */ +export function publicChannelHashes( + secretsEnv = process.env['MESHCORE_CHANNEL_SECRETS'], + allowlist = process.env['PUBLIC_DECRYPT_CHANNELS'], +): Set { + const hashes = new Set(); + for (const entry of buildChannelEntries(secretsEnv)) { + if (entry.classification === 'public' || isPublicChannelName(entry.name, allowlist)) { + hashes.add(ChannelCrypto.calculateChannelHash(entry.secret).toLowerCase()); + } + } + return hashes; +} + +export function isPublicChannelHash( + hash: string, + secretsEnv = process.env['MESHCORE_CHANNEL_SECRETS'], + allowlist = process.env['PUBLIC_DECRYPT_CHANNELS'], +): boolean { + const normalized = hash.trim().toLowerCase(); + return normalized.length > 0 && publicChannelHashes(secretsEnv, allowlist).has(normalized); +} + +/** + * Strip plaintext from a packet payload unless its channel is explicitly public. + * Keeps the pre-computed summary for public channels only; confidential channel + * content never leaves the database through the public packet-detail API. + */ +export function sanitizePublicPacketPayload( + payload: Record | null, + secretsEnv = process.env['MESHCORE_CHANNEL_SECRETS'], + allowlist = process.env['PUBLIC_DECRYPT_CHANNELS'], +): Record | null { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload; + const decrypted = payload['decrypted']; + if (!decrypted || typeof decrypted !== 'object' || Array.isArray(decrypted)) return payload; + const channelHash = String((decrypted as Record)['channelHash'] ?? ''); + if (channelHash && isPublicChannelHash(channelHash, secretsEnv, allowlist)) return payload; + const { decrypted: _decrypted, _summary: _summary, ...rest } = payload; + return rest; +} + // Small cache so relay copies of the same GroupText don't trigger re-decodes const channelCache = new BoundedTtlMap({ name: 'mqtt_channels', diff --git a/docker-compose.yml b/docker-compose.yml index 190f9fe..9772f16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,7 @@ services: MESHCORE_REGION_NAMES: ${MESHCORE_REGION_NAMES:-eng-ne,Europe,Global} OWNER_DATABASE_URL: ${OWNER_DATABASE_URL:-postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${OWNER_POSTGRES_DB:-meshcore_owner_auth}} OWNER_MQTT_USERNAME_MAP: ${OWNER_MQTT_USERNAME_MAP:-} + PUBLIC_DECRYPT_CHANNELS: ${PUBLIC_DECRYPT_CHANNELS:-} OWNER_AUTHORIZATION_MODE: ${OWNER_AUTHORIZATION_MODE:-enforce} OWNER_ACL_MODE: ${OWNER_ACL_MODE:-apply} OWNER_ACL_UNMANAGED_USERS: ${OWNER_ACL_UNMANAGED_USERS:-backend,test,test2}