mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-15 10:40:01 +00:00
security: revalidate MQTT auth and revoke owner sessions
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"stats:seed-chart-snapshot": "node dist/tools/seedStatsChartSnapshot.js",
|
"stats:seed-chart-snapshot": "node dist/tools/seedStatsChartSnapshot.js",
|
||||||
"db:lifecycle": "node dist/tools/dataLifecycle.js",
|
"db:lifecycle": "node dist/tools/dataLifecycle.js",
|
||||||
"owner-auth:inventory": "node dist/tools/inventoryOwnerAuthorization.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",
|
"analysis:cleanup-orphans": "node dist/tools/cleanupOrphanedAnalysisRuns.js",
|
||||||
"bench:decode": "tsx src/tools/benchmarkPacketDecode.ts",
|
"bench:decode": "tsx src/tools/benchmarkPacketDecode.ts",
|
||||||
"load:realtime": "tsx src/tools/loadRealtime.ts",
|
"load:realtime": "tsx src/tools/loadRealtime.ts",
|
||||||
|
|||||||
@@ -138,9 +138,8 @@ async function requireOwnerSession(req: Request, res: Response): Promise<string[
|
|||||||
res.status(401).json({ error: 'Not logged in' });
|
res.status(401).json({ error: 'Not logged in' });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// BUG-010: reject sessions minted under a revoked password. The generation
|
// Reject sessions minted before an operator password reset. The revocation
|
||||||
// is bumped when the broker rejects a previously-valid credential; sessions
|
// tool bumps the generation; every owner request checks it before authorizing.
|
||||||
// carrying an older generation are stale and must re-authenticate.
|
|
||||||
const currentGen = await getOwnerCredentialGeneration(session.mqttUsername);
|
const currentGen = await getOwnerCredentialGeneration(session.mqttUsername);
|
||||||
if (session.gen !== currentGen) {
|
if (session.gen !== currentGen) {
|
||||||
res.clearCookie(OWNER_COOKIE_NAME, { path: '/' });
|
res.clearCookie(OWNER_COOKIE_NAME, { path: '/' });
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import test from 'node:test';
|
||||||
|
import express, { Router, type RequestHandler } from 'express';
|
||||||
|
import type { QueryResultRow } from 'pg';
|
||||||
|
import { registerOwnerRoutes } from './owner.js';
|
||||||
|
import { getOwnerSession, encryptOwnerSession } from '../../owner/ownerSession.js';
|
||||||
|
|
||||||
|
const NODE_ID = 'A1'.repeat(32);
|
||||||
|
const passThroughLimiter: RequestHandler = (_req, _res, next) => next();
|
||||||
|
const TEST_COOKIE_SECRET = 'test-owner-cookie-secret-at-least-32-bytes';
|
||||||
|
|
||||||
|
async function startOwnerRoutes(options: {
|
||||||
|
currentGeneration?: () => Promise<number>;
|
||||||
|
verifyCredentials?: (username: string, password: string) => Promise<boolean>;
|
||||||
|
} = {}): Promise<{ baseUrl: string; close: () => Promise<void>; 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<typeof registerOwnerRoutes>[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 <T extends QueryResultRow = QueryResultRow>() => ({ 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type BrokerCredentialCheck = (mqttUsername: string, mqttPassword: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import mqtt from 'mqtt';
|
import mqtt from 'mqtt';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { BoundedTtlMap } from '../cache/boundedTtlMap.js';
|
import { BoundedTtlMap } from '../cache/boundedTtlMap.js';
|
||||||
import { getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
|
import { getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
|
||||||
@@ -9,6 +9,7 @@ import { getRedisConnectionOptions, getRedisUrl } from '../platform/config/redis
|
|||||||
import { reconcileOwnerAuthorization } from './ownerAclReconciler.js';
|
import { reconcileOwnerAuthorization } from './ownerAclReconciler.js';
|
||||||
import { groupOwnerNodes, type OwnerDashboardRow } from './ownerDashboard.js';
|
import { groupOwnerNodes, type OwnerDashboardRow } from './ownerDashboard.js';
|
||||||
import { parseOwnerGrantConfig } from './ownerGrantConfig.js';
|
import { parseOwnerGrantConfig } from './ownerGrantConfig.js';
|
||||||
|
import { createMqttCredentialVerifier } from './mqttCredentialVerifier.js';
|
||||||
|
|
||||||
function normalizeNodeIds(nodeIds: string[]): string[] {
|
function normalizeNodeIds(nodeIds: string[]): string[] {
|
||||||
return Array.from(new Set(
|
return Array.from(new Set(
|
||||||
@@ -93,50 +94,12 @@ export async function autoLinkOwnerNodeIds(mqttUsername: string): Promise<string
|
|||||||
return resolveOwnerNodeIds(mqttUsername);
|
return resolveOwnerNodeIds(mqttUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verifying credentials means opening a full MQTT connection to the broker
|
export const verifyMqttCredentials = createMqttCredentialVerifier(verifyMqttCredentialsViaBroker);
|
||||||
// (5s connect timeout, 6s hard cap) — the dominant cost of a login. Cache
|
|
||||||
// successful verifications per username for a short window so repeat logins
|
|
||||||
// (and the 30-day cookie re-auth flows) skip the broker round-trip. The cache
|
|
||||||
// key is a hash of the exact username+password, so a changed/rotated password
|
|
||||||
// misses and re-verifies. Only positive results are cached.
|
|
||||||
const AUTH_CACHE_TTL_MS = Number(process.env['OWNER_AUTH_CACHE_TTL_MS'] ?? 5 * 60_000);
|
|
||||||
const authCache = new BoundedTtlMap<string, { credentialHash: string; ts: number }>({
|
|
||||||
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<boolean> {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Credential generation (BUG-010) ----
|
// ---- Credential generation (BUG-010) ----
|
||||||
// Owner sessions are stateless signed cookies with a 30-day TTL. To make
|
// Owner sessions record the credential generation that minted them. The
|
||||||
// password revocation effective immediately, every session records the
|
// operator revocation command bumps this Redis value after a password reset,
|
||||||
// credential generation that minted it. When the broker rejects a previously
|
// and every authenticated owner request compares it with the cookie generation.
|
||||||
// valid password (the only signal the backend has that a password changed),
|
|
||||||
// the generation is bumped in Redis and all older sessions stop validating.
|
|
||||||
const OWNER_CRED_GEN_KEY = (username: string) => `owner:credential-gen:${username}`;
|
const OWNER_CRED_GEN_KEY = (username: string) => `owner:credential-gen:${username}`;
|
||||||
const OWNER_CRED_GEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
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<number> {
|
export async function getOwnerCredentialGeneration(mqttUsername: string): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const value = await getCredGenRedis().get(OWNER_CRED_GEN_KEY(mqttUsername));
|
const value = await getCredGenRedis().get(OWNER_CRED_GEN_KEY(mqttUsername));
|
||||||
const parsed = value === null ? NaN : Number(value);
|
if (value === null) return 0;
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new Error('INVALID_OWNER_CREDENTIAL_GENERATION');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[owner-credgen] read failed, treating as 0', error instanceof Error ? error.message : error);
|
console.error('[owner-credgen] read failed', error instanceof Error ? error.message : error);
|
||||||
return 0;
|
throw new Error('OWNER_CREDENTIAL_GENERATION_UNAVAILABLE', { cause: error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bump the credential generation, invalidating every session minted under an
|
* Bump the credential generation, invalidating every session minted under an
|
||||||
* older password. Failures are logged but non-fatal: the next login still
|
* older password. Errors propagate so an operator reset cannot report success
|
||||||
* succeeds and re-records the current generation.
|
* unless revocation was recorded.
|
||||||
*/
|
*/
|
||||||
export async function bumpOwnerCredentialGeneration(mqttUsername: string): Promise<void> {
|
export async function bumpOwnerCredentialGeneration(mqttUsername: string): Promise<void> {
|
||||||
try {
|
const redis = getCredGenRedis();
|
||||||
const redis = getCredGenRedis();
|
const key = OWNER_CRED_GEN_KEY(mqttUsername);
|
||||||
await redis.incr(OWNER_CRED_GEN_KEY(mqttUsername));
|
await redis.incr(key);
|
||||||
await redis.pexpire(OWNER_CRED_GEN_KEY(mqttUsername), OWNER_CRED_GEN_TTL_MS);
|
await redis.pexpire(key, OWNER_CRED_GEN_TTL_MS);
|
||||||
} catch (error) {
|
}
|
||||||
console.error('[owner-credgen] bump failed', error instanceof Error ? error.message : error);
|
|
||||||
}
|
export async function closeOwnerCredentialGenerationClient(): Promise<void> {
|
||||||
|
if (!credGenRedis) return;
|
||||||
|
const client = credGenRedis;
|
||||||
|
credGenRedis = null;
|
||||||
|
await client.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyMqttCredentialsViaBroker(mqttUsername: string, mqttPassword: string): Promise<boolean> {
|
function verifyMqttCredentialsViaBroker(mqttUsername: string, mqttPassword: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
bumpOwnerCredentialGeneration,
|
||||||
|
closeOwnerCredentialGenerationClient,
|
||||||
|
} from '../owner/ownerAccess.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
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 <mqtt-username>');
|
||||||
|
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();
|
||||||
@@ -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 <mqtt-username>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user