mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-23 14:22:47 +00:00
fix(security): gate channel plaintext to public channels
UKM-C-02: every key in MESHCORE_CHANNEL_SECRETS was publishable by name via /feed/messages, and packet detail returned payload.decrypted for any hash. Committed PUBLIC_CHANNELS remain public; env-supplied secrets are now confidential unless their name is explicitly listed in PUBLIC_DECRYPT_CHANNELS. The feed route 404s for non-public names, the history query fails closed, and packet detail strips decrypted/_summary unless the channel hash is public.
This commit is contained in:
1 parent
866e5c7583
commit
9b77aac103
6 files changed
+100
-7
No files matched your search
+4
-4
@@ -176,10 +176,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,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']);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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:-shadow}
|
||||
OWNER_ACL_MODE: ${OWNER_ACL_MODE:-shadow}
|
||||
OWNER_ACL_UNMANAGED_USERS: ${OWNER_ACL_UNMANAGED_USERS:-backend,test,test2}
|
||||
|
||||
Reference in new issue
Block a user