perf,feat(events),hardening: second review pass
Fleet overview N+1: - InstanceResourceService gains a 30s TTL cache with single-flight coalescing; the overview no longer fires six live upstream requests per device on every render (plus the re-login storm), while the per-device detail route probes live via force:true Event journal becomes live: - job terminal transitions (manual executions and the interrupted sweep) now append to the journal, so /api/v1/events SSE feeds the frontend's invalidation controller that was built but never received events - journal pruning moves off the append hot path (was an unindexable full-table json_extract scan per insert) onto the retention timer Console auth hardening: - scrypt upgraded from N=16384 to N=2^16 (OWASP interactive guidance); a new password_kdf column records the derivation per row and legacy hashes rehash transparently on the next successful login without invalidating sessions (migration 18) Legacy stack: - instance URL validation blocks IPv4-compatible IPv6 after WHATWG canonicalization (::a9fe:a9fe metadata, ::7f00:1 loopback slipped past) - status polls cool down auto-login for 60s after a failed attempt so a stale saved password cannot hammer the device into an account lockout Build hygiene: - web bundle splits app (410kB) from vendor (212kB) so framework code stays cacheable across releases; stale root package-lock.json removed (pnpm is the only lockfile)
This commit is contained in:
@@ -6,6 +6,25 @@ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const MAX_PASSWORD_BYTES = 1024;
|
||||
const SCRYPT_KEY_LENGTH = 32;
|
||||
// Legacy derivations used N=16384; OWASP guidance for interactive login sits at
|
||||
// N=2^16/r=8. Rows keep their derivation tag so verification stays correct and
|
||||
// the upgrade happens transparently on the next successful login.
|
||||
interface ScryptParams {
|
||||
readonly N: number;
|
||||
readonly r: number;
|
||||
readonly p: number;
|
||||
}
|
||||
const SCRYPT_LEGACY: ScryptParams = { N: 16_384, r: 8, p: 1 };
|
||||
const SCRYPT_CURRENT: ScryptParams = { N: 65_536, r: 8, p: 1 };
|
||||
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
||||
const scryptTag = (params: ScryptParams): string => `scrypt:${params.N}:${params.r}:${params.p}`;
|
||||
const parseScryptTag = (value: string | null | undefined): ScryptParams => {
|
||||
if (!value) return SCRYPT_LEGACY;
|
||||
const match = /^scrypt:(\d+):(\d+):(\d+)$/.exec(value);
|
||||
if (!match) return SCRYPT_LEGACY;
|
||||
const [N, r, p] = match.slice(1).map(Number) as [number, number, number];
|
||||
return { N, r, p };
|
||||
};
|
||||
const IDLE_TIMEOUT_SETTING_KEY = 'console-auth.idle-timeout-minutes';
|
||||
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
|
||||
const MIN_IDLE_TIMEOUT_MINUTES = 5;
|
||||
@@ -16,6 +35,7 @@ interface AuthConfigRow {
|
||||
readonly password_salt: string | null;
|
||||
readonly password_hash: string | null;
|
||||
readonly password_revision: number;
|
||||
readonly password_kdf: string | null;
|
||||
}
|
||||
|
||||
export interface ConsoleAuthStatus {
|
||||
@@ -87,10 +107,23 @@ export class ConsoleAuthService {
|
||||
!config.password_hash
|
||||
)
|
||||
throw new ConsoleAuthError('INVALID_CREDENTIALS');
|
||||
const actual = await this.derive(password, config.password_salt);
|
||||
const params = parseScryptTag(config.password_kdf);
|
||||
const actual = await this.derive(password, config.password_salt, params);
|
||||
const expected = Buffer.from(config.password_hash, 'hex');
|
||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||
throw new ConsoleAuthError('INVALID_CREDENTIALS');
|
||||
if (params !== SCRYPT_CURRENT) {
|
||||
// Transparent upgrade: same salt, stronger derivation, same revision so
|
||||
// live sessions survive.
|
||||
const hash = (await this.derive(password, config.password_salt, SCRYPT_CURRENT)).toString(
|
||||
'hex',
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
'UPDATE console_auth_config SET password_hash=?,password_kdf=?,updated_at=? WHERE singleton=1',
|
||||
)
|
||||
.run(hash, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
|
||||
}
|
||||
return this.createSession(config.password_revision);
|
||||
}
|
||||
|
||||
@@ -121,14 +154,14 @@ export class ConsoleAuthService {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO console_auth_config
|
||||
(singleton,protection_enabled,password_salt,password_hash,password_revision,updated_at)
|
||||
VALUES (1,1,?,?,?,?)
|
||||
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
|
||||
VALUES (1,1,?,?,?,?,?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET
|
||||
protection_enabled=1,password_salt=excluded.password_salt,
|
||||
password_hash=excluded.password_hash,password_revision=excluded.password_revision,
|
||||
updated_at=excluded.updated_at`,
|
||||
password_kdf=excluded.password_kdf,updated_at=excluded.updated_at`,
|
||||
)
|
||||
.run(salt, hash, revision, this.now().toISOString());
|
||||
.run(salt, hash, revision, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
|
||||
this.db.prepare('DELETE FROM console_auth_sessions').run();
|
||||
return this.createSession(revision);
|
||||
})();
|
||||
@@ -178,7 +211,7 @@ export class ConsoleAuthService {
|
||||
private config(): AuthConfigRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT protection_enabled,password_salt,password_hash,password_revision
|
||||
`SELECT protection_enabled,password_salt,password_hash,password_revision,password_kdf
|
||||
FROM console_auth_config WHERE singleton=1`,
|
||||
)
|
||||
.get() as AuthConfigRow | undefined;
|
||||
@@ -221,13 +254,17 @@ export class ConsoleAuthService {
|
||||
throw new ConsoleAuthError('PASSWORD_POLICY_FAILED');
|
||||
}
|
||||
|
||||
private derive(password: string, salt: string): Promise<Buffer> {
|
||||
private derive(
|
||||
password: string,
|
||||
salt: string,
|
||||
params: ScryptParams = SCRYPT_CURRENT,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
deriveScrypt(
|
||||
password,
|
||||
Buffer.from(salt, 'hex'),
|
||||
SCRYPT_KEY_LENGTH,
|
||||
{ N: 16_384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 },
|
||||
{ ...params, maxmem: SCRYPT_MAXMEM },
|
||||
(error, derivedKey) => {
|
||||
if (error) reject(error);
|
||||
else resolvePromise(derivedKey);
|
||||
|
||||
@@ -185,7 +185,6 @@ export class EventJournal {
|
||||
const sequence = Number(result.lastInsertRowid);
|
||||
if (!Number.isSafeInteger(sequence))
|
||||
throw new Error('event journal sequence is not a safe integer');
|
||||
this.#prune();
|
||||
const appended = { sequence, envelope };
|
||||
for (const subscriber of [...this.#subscribers]) subscriber(appended);
|
||||
return appended;
|
||||
@@ -236,7 +235,11 @@ export class EventJournal {
|
||||
};
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
/**
|
||||
* Maintenance hook for a periodic timer. Pruning per append turned every
|
||||
* insert into an unindexable full-table json_extract scan.
|
||||
*/
|
||||
prune(): void {
|
||||
const cutoff = new Date(Date.now() - this.#retentionMs).toISOString();
|
||||
this.#database
|
||||
.prepare("DELETE FROM event_journal WHERE json_extract(envelope_json, '$.occurredAt') < ?")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import type { EventEnvelope } from '../events/event-journal.js';
|
||||
import type {
|
||||
ExecuteOperationRequest,
|
||||
Job,
|
||||
@@ -121,6 +122,8 @@ interface Options {
|
||||
readonly idFactory?: () => string;
|
||||
readonly tokenFactory?: () => string;
|
||||
readonly nonceFactory?: () => string;
|
||||
/** Receives job terminal transitions so the SSE journal has a live producer. */
|
||||
readonly emit?: (envelope: EventEnvelope) => void;
|
||||
}
|
||||
interface PreparationRow {
|
||||
operation_id: string;
|
||||
@@ -442,6 +445,7 @@ export class SecureOperationExecution {
|
||||
}
|
||||
}
|
||||
this.finish(ids, state, code);
|
||||
this.#emitTerminal(ids.job, state, requestId);
|
||||
return this.job(ids.job);
|
||||
}
|
||||
|
||||
@@ -453,7 +457,10 @@ export class SecureOperationExecution {
|
||||
"SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postBasebandRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'",
|
||||
)
|
||||
.all() as Array<{ id: string }>;
|
||||
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
|
||||
for (const row of jobs) {
|
||||
this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
|
||||
this.#emitTerminal(row.id, 'unknown-result', now);
|
||||
}
|
||||
return jobs.length;
|
||||
})();
|
||||
}
|
||||
@@ -479,6 +486,21 @@ export class SecureOperationExecution {
|
||||
.run(state, now, now, ids.job);
|
||||
})();
|
||||
}
|
||||
#emitTerminal(jobId: string, state: string, requestId: string): void {
|
||||
if (!this.options.emit) return;
|
||||
try {
|
||||
this.options.emit({
|
||||
kind: 'job',
|
||||
id: this.id(),
|
||||
occurredAt: this.clock().toISOString(),
|
||||
requestId,
|
||||
jobId,
|
||||
});
|
||||
} catch {
|
||||
// A journal failure must never fail the operation itself.
|
||||
}
|
||||
}
|
||||
|
||||
private finishByJob(jobId: string, now: string, state: string, code: string): void {
|
||||
this.options.db
|
||||
.prepare(
|
||||
|
||||
@@ -155,6 +155,66 @@ describe('instance resource allowlist parsing', () => {
|
||||
).toEqual({ simPresent: true, phoneNumbers: ['+8613800000000'] });
|
||||
});
|
||||
|
||||
describe('overview cache', () => {
|
||||
const minimal = (cacheTtlMs?: number) => {
|
||||
const requests: Array<{ url: string }> = [];
|
||||
const service = new InstanceResourceService({
|
||||
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
|
||||
sessions: { sessionFor: () => undefined } as never,
|
||||
request: async (request: { url: string }) => {
|
||||
requests.push(request);
|
||||
return request.url.endsWith('/api/device')
|
||||
? response({ data: { online: true } })
|
||||
: { status: 503, headers: {}, body: '' };
|
||||
},
|
||||
...(cacheTtlMs === undefined ? {} : { cacheTtlMs }),
|
||||
} as never);
|
||||
return { service, requests };
|
||||
};
|
||||
|
||||
it('serves repeat overview reads from the TTL cache and refreshes after expiry', async () => {
|
||||
let clock = 1_000;
|
||||
const { service, requests } = minimal();
|
||||
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
|
||||
|
||||
await service.get('device-1');
|
||||
const firstPass = requests.length;
|
||||
await service.get('device-1');
|
||||
expect(requests.length).toBe(firstPass);
|
||||
clock += 31_000;
|
||||
await service.get('device-1');
|
||||
expect(requests.length).toBeGreaterThan(firstPass);
|
||||
});
|
||||
|
||||
it('coalesces concurrent overview reads into one upstream pass', async () => {
|
||||
const { service, requests } = minimal();
|
||||
await Promise.all([
|
||||
service.get('device-1'),
|
||||
service.get('device-1'),
|
||||
service.get('device-1'),
|
||||
]);
|
||||
expect(requests).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('probes live again when a detail view forces a refresh', async () => {
|
||||
const clock = 5_000;
|
||||
const { service, requests } = minimal();
|
||||
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
|
||||
|
||||
await service.get('device-1');
|
||||
const afterOverview = requests.length;
|
||||
await service.get('device-1', { force: true });
|
||||
expect(requests.length).toBe(afterOverview + 6);
|
||||
});
|
||||
|
||||
it('keeps reads uncached when the TTL is disabled', async () => {
|
||||
const { service, requests } = minimal(0);
|
||||
await service.get('device-1');
|
||||
await service.get('device-1');
|
||||
expect(requests).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
||||
it('probes passwordless instances without manufacturing a cookie', async () => {
|
||||
const requests: Array<{ url: string; headers: Readonly<Record<string, string>> }> = [];
|
||||
const service = new InstanceResourceService({
|
||||
|
||||
@@ -163,7 +163,15 @@ export function parseSignalStrength(response: UpstreamResponse): InstanceResourc
|
||||
return signalPercent === undefined ? {} : { signalPercent };
|
||||
}
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 30_000;
|
||||
|
||||
export class InstanceResourceService {
|
||||
readonly #cache = new Map<
|
||||
string,
|
||||
{ readonly resources: InstanceResources; readonly fetchedAt: number }
|
||||
>();
|
||||
readonly #inflight = new Map<string, Promise<InstanceResources>>();
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
readonly instances: InstanceService;
|
||||
@@ -174,10 +182,46 @@ export class InstanceResourceService {
|
||||
origin: string,
|
||||
force?: boolean,
|
||||
) => Promise<void>;
|
||||
/** Fleet overview fan-out reads through this cache; 0 disables it. */
|
||||
readonly cacheTtlMs?: number;
|
||||
readonly now?: () => number;
|
||||
},
|
||||
) {}
|
||||
|
||||
async get(instanceId: string): Promise<InstanceResources> {
|
||||
/**
|
||||
* Fleet overview fans out one read per registered node; without a shared TTL
|
||||
* that is six live upstream requests per device on every page render (plus a
|
||||
* re-login storm when sessions expired). Detail views pass `force` to probe
|
||||
* the selected device immediately.
|
||||
*/
|
||||
async get(
|
||||
instanceId: string,
|
||||
{ force = false }: { readonly force?: boolean } = {},
|
||||
): Promise<InstanceResources> {
|
||||
const ttl = this.options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||
const now = this.options.now?.() ?? Date.now();
|
||||
if (ttl > 0 && !force) {
|
||||
const cached = this.#cache.get(instanceId);
|
||||
if (cached && now - cached.fetchedAt < ttl) return cached.resources;
|
||||
const inflight = this.#inflight.get(instanceId);
|
||||
if (inflight) return inflight;
|
||||
const pending = this.#fetch(instanceId)
|
||||
.then((resources) => {
|
||||
this.#cache.set(instanceId, { resources, fetchedAt: this.options.now?.() ?? now });
|
||||
return resources;
|
||||
})
|
||||
.finally(() => {
|
||||
this.#inflight.delete(instanceId);
|
||||
});
|
||||
this.#inflight.set(instanceId, pending);
|
||||
return pending;
|
||||
}
|
||||
const resources = await this.#fetch(instanceId);
|
||||
if (ttl > 0) this.#cache.set(instanceId, { resources, fetchedAt: now });
|
||||
return resources;
|
||||
}
|
||||
|
||||
async #fetch(instanceId: string): Promise<InstanceResources> {
|
||||
const [instance, session] = await Promise.all([
|
||||
this.options.instances.get(instanceId),
|
||||
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
|
||||
|
||||
@@ -381,6 +381,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
const secureExecution = new SecureOperationExecution({
|
||||
db: options.db,
|
||||
registry: secureOperationRegistry,
|
||||
emit: (envelope) => eventJournal.append(envelope),
|
||||
transport: {
|
||||
request: async ({ origin, path, body, contentType, instanceId }) => {
|
||||
const cookie = await resolveOperationCookie(instanceId, origin);
|
||||
@@ -586,6 +587,9 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
void Promise.resolve()
|
||||
.then(() => smsOutbox.prune(30 * 24 * 60 * 60 * 1000))
|
||||
.catch(() => undefined);
|
||||
void Promise.resolve()
|
||||
.then(() => eventJournal.prune())
|
||||
.catch(() => undefined);
|
||||
}, logPruneIntervalMs)
|
||||
: undefined;
|
||||
// Scheduled component backups: the tick is cheap and only writes when a period is overdue.
|
||||
|
||||
@@ -670,6 +670,16 @@ export const MIGRATIONS: readonly Migration[] = [
|
||||
'CREATE INDEX idx_device_identities_imei ON device_identities(imei, instance_id)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
name: 'console-auth-scrypt-params',
|
||||
statements: [
|
||||
// Legacy hashes were derived with N=16384; new derivations use stronger
|
||||
// parameters. The column records the derivation per row; NULL means legacy
|
||||
// and is transparently upgraded after the next successful login.
|
||||
'ALTER TABLE console_auth_config ADD COLUMN password_kdf TEXT',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
||||
|
||||
@@ -49,6 +49,49 @@ afterEach(async () => {
|
||||
for (const db of dbs.splice(0)) if (db.open) db.close();
|
||||
});
|
||||
|
||||
async function seedLegacyHash(db: Database.Database, password: string): Promise<void> {
|
||||
const { scrypt } = await import('node:crypto');
|
||||
const salt = 'a'.repeat(32);
|
||||
const hash = await new Promise<Buffer>((resolve, reject) =>
|
||||
scrypt(password, Buffer.from(salt, 'hex'), 32, { N: 16_384, r: 8, p: 1 }, (error, key) =>
|
||||
error ? reject(error) : resolve(key),
|
||||
),
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO console_auth_config
|
||||
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
|
||||
VALUES (1,1,?,?,1,NULL,?)`,
|
||||
).run(salt, hash.toString('hex'), new Date().toISOString());
|
||||
}
|
||||
|
||||
describe('console password derivation upgrade', () => {
|
||||
it('verifies a legacy N=16384 hash and transparently rehashes with current params', async () => {
|
||||
const { app, db, auth } = fixture();
|
||||
await seedLegacyHash(db, PASSWORD);
|
||||
|
||||
const before = db
|
||||
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
|
||||
.get() as { password_hash: string; password_kdf: string | null };
|
||||
expect(before.password_kdf).toBeNull();
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { password: PASSWORD },
|
||||
});
|
||||
expect(login.statusCode).toBe(204);
|
||||
|
||||
const after = db
|
||||
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
|
||||
.get() as { password_hash: string; password_kdf: string | null };
|
||||
expect(after.password_kdf).toBe('scrypt:65536:8:1');
|
||||
expect(after.password_hash).not.toBe(before.password_hash);
|
||||
|
||||
// The upgraded hash still verifies with a plain login through the service.
|
||||
await expect(auth.login(PASSWORD)).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate-console password protection HTTP boundary', () => {
|
||||
it('starts migration-safe as disabled and never returns password material', async () => {
|
||||
const { app } = fixture();
|
||||
|
||||
@@ -370,7 +370,10 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
||||
app.get(
|
||||
'/api/v1/instances/:instanceId/resources',
|
||||
wrap(async (request) =>
|
||||
options.resources!.get((request.params as { instanceId: string }).instanceId),
|
||||
// A single selected device is the one place a live probe is expected.
|
||||
options.resources!.get((request.params as { instanceId: string }).instanceId, {
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (options.messages) {
|
||||
|
||||
Reference in New Issue
Block a user