Merge fix: gate channel plaintext to public channels (UKM-C-02)

PUBLIC_CHANNELS remain public; env-supplied secrets require PUBLIC_DECRYPT_CHANNELS. Feed route 404s non-public names; packet detail strips confidential plaintext.
This commit is contained in:
hermes-gadget authored and GitHub committed 2026-10-21 01:01:46 +01:00
commit b414c58987
6 files changed
+104 -7

No files matched your search

+8 -4
View File
@@ -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.
+5
View File
@@ -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']);
+3 -2
View File
@@ -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<string, unknown> | null,
payload: sanitizePublicPacketPayload(row.payload as Record<string, unknown> | null),
pathHashes: row.path_hashes as string[] | null,
pathHashSizeBytes: row.path_hash_size_bytes as number | null,
rawHex: row.raw_hex as string | null,
+25 -1
View File
@@ -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' });
});
+62
View File
@@ -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<string> {
const hashes = new Set<string>();
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<string, unknown> | null,
secretsEnv = process.env['MESHCORE_CHANNEL_SECRETS'],
allowlist = process.env['PUBLIC_DECRYPT_CHANNELS'],
): Record<string, unknown> | 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<string, unknown>)['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<string, string | null>({
name: 'mqtt_channels',
+1
View File
@@ -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}