From 6e9f23deb6b9b54c0e6679da8de79927950075e7 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Thu, 25 Jun 2026 11:58:22 +0200 Subject: [PATCH 1/6] feat(node-message-broker): add E2E crypto adapter Slice S1 of the node message-broker build-out (Plan 013 Track B): the node-to-node end-to-end crypto port and its @privateaim/kit adapter, so the broker can seal outbound and open inbound messages while the Hub only ever stores opaque ciphertext. - core/crypto: ICryptoService port (seal/open with an optional HKDF `info` context passthrough for later analysis/message binding). - adapters/crypto: CryptoService wrapping the kit's sealMessage/openMessage (ECDH P-256 -> per-message HKDF -> AES-256-GCM). Holds the node's single private key (hex PKCS#8 PEM from config.nodePrivateKey), lazily imported and cached; peer public keys (hex SPKI PEM) imported with empty usages and cached by hex, evicting failed imports so a corrected key can retry. - No AES/HKDF/nonce handling here and no DI wiring yet (that is S6). - Tests: A->B round-trip (bytes + string), per-message salt randomness, info match/mismatch, wrong-recipient, salt/IV and ciphertext/tag tampering, and the missing/invalid/non-hex key error paths. Closes #4 --- .../src/adapters/crypto/index.ts | 8 + .../src/adapters/crypto/service.ts | 114 ++++++++ .../src/core/crypto/index.ts | 8 + .../src/core/crypto/types.ts | 44 +++ .../test/unit/adapters/crypto/service.spec.ts | 253 ++++++++++++++++++ 5 files changed, 427 insertions(+) create mode 100644 apps/node-message-broker/src/adapters/crypto/index.ts create mode 100644 apps/node-message-broker/src/adapters/crypto/service.ts create mode 100644 apps/node-message-broker/src/core/crypto/index.ts create mode 100644 apps/node-message-broker/src/core/crypto/types.ts create mode 100644 apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts diff --git a/apps/node-message-broker/src/adapters/crypto/index.ts b/apps/node-message-broker/src/adapters/crypto/index.ts new file mode 100644 index 0000000..eb339f0 --- /dev/null +++ b/apps/node-message-broker/src/adapters/crypto/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export * from './service.ts'; diff --git a/apps/node-message-broker/src/adapters/crypto/service.ts b/apps/node-message-broker/src/adapters/crypto/service.ts new file mode 100644 index 0000000..270a07e --- /dev/null +++ b/apps/node-message-broker/src/adapters/crypto/service.ts @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + hexToUTF8, + importAsymmetricPrivateKey, + importAsymmetricPublicKey, + isHex, + openMessage, + sealMessage, +} from '@privateaim/kit'; +import type { ICryptoService, MessageSealInput } from '../../core/crypto/index.ts'; + +const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' } as const; + +type CryptoServiceContext = { + privateKey?: string, // hex-encoded PKCS#8 PEM (config.nodePrivateKey, OPTIONAL) +}; + +/** + * End-to-end crypto adapter backed by `@privateaim/kit`. + * + * Holds the node's single ECDH private key (hex-encoded PKCS#8 PEM, supplied by + * the operator via `config.nodePrivateKey`, lazily imported once and cached). + * Peer ECDH public keys arrive hex-encoded SPKI PEM and are imported with EMPTY + * usages and cached by their hex string. `seal` / `open` delegate to the kit's + * `sealMessage` / `openMessage` (per-message HKDF salt + AES-256-GCM); this + * adapter never touches AES, HKDF or nonces directly. + */ +export class CryptoService implements ICryptoService { + protected privateKey?: string; + + private privateKeyPromise?: Promise; + + // Unbounded by design, but bounded in practice by the operator-controlled set + // of peer nodes; keyed by the raw hex PEM so distinct keys never collide. + private readonly publicKeyCache = new Map>(); + + constructor(ctx: CryptoServiceContext) { + this.privateKey = ctx.privateKey; + } + + private getPrivateKey(): Promise { + if (this.privateKeyPromise) { + return this.privateKeyPromise; + } + + const hex = this.privateKey; + if (!hex) { + throw new Error('Node private key is not configured (NODE_PRIVATE_KEY is missing).'); + } + + if (!isHex(hex)) { + throw new Error('Node private key is not a valid hex-encoded PEM string.'); + } + + // Cache the in-flight import, but evict a rejected result so a corrected + // key can be retried rather than failing forever. + const promise = importAsymmetricPrivateKey(hexToUTF8(hex), ECDH_PARAMS); + promise.catch(() => { + if (this.privateKeyPromise === promise) { + this.privateKeyPromise = undefined; + } + }); + this.privateKeyPromise = promise; + return promise; + } + + private getPublicKey(hex: string): Promise { + const cached = this.publicKeyCache.get(hex); + if (cached) { + return cached; + } + + if (!isHex(hex)) { + throw new Error('Peer public key is not a valid hex-encoded PEM string.'); + } + + const promise = importAsymmetricPublicKey(hexToUTF8(hex), ECDH_PARAMS); + promise.catch(() => { + if (this.publicKeyCache.get(hex) === promise) { + this.publicKeyCache.delete(hex); + } + }); + this.publicKeyCache.set(hex, promise); + return promise; + } + + async seal(data: MessageSealInput, recipientPublicKey: string, info?: MessageSealInput): Promise { + const privateKey = await this.getPrivateKey(); + const publicKey = await this.getPublicKey(recipientPublicKey); + return sealMessage({ + privateKey, + publicKey, + data, + info, + }); + } + + async open(payload: string, senderPublicKey: string, info?: MessageSealInput): Promise { + const privateKey = await this.getPrivateKey(); + const publicKey = await this.getPublicKey(senderPublicKey); + return openMessage({ + privateKey, + publicKey, + payload, + info, + }); + } +} diff --git a/apps/node-message-broker/src/core/crypto/index.ts b/apps/node-message-broker/src/core/crypto/index.ts new file mode 100644 index 0000000..0b5c307 --- /dev/null +++ b/apps/node-message-broker/src/core/crypto/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export * from './types.ts'; diff --git a/apps/node-message-broker/src/core/crypto/types.ts b/apps/node-message-broker/src/core/crypto/types.ts new file mode 100644 index 0000000..ead5959 --- /dev/null +++ b/apps/node-message-broker/src/core/crypto/types.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import type { MessageSealInput } from '@privateaim/kit'; + +export type { MessageSealInput }; + +/** + * Node-to-node end-to-end crypto port. + * + * Seals outbound messages for a recipient node and opens inbound messages from a + * sender node, using ECDH (P-256) + per-message HKDF + AES-256-GCM via + * `@privateaim/kit`'s `sealMessage` / `openMessage`. The node holds exactly one + * ECDH keypair; the operator supplies the private key (hex-encoded PKCS#8 PEM) + * and peer public keys arrive hex-encoded SPKI PEM. The hub only ever sees the + * opaque base64 frame and never decrypts it. + * + * Implemented by `CryptoService` in `adapters/crypto/service.ts`. + */ +export interface ICryptoService { + /** + * Seal `data` for the recipient node. Returns the base64 frame + * (`salt ‖ iv ‖ ciphertext‖tag`) to relay through the hub. + * + * @param data plaintext (bytes or UTF-8 string) + * @param recipientPublicKey recipient node ECDH public key, hex-encoded SPKI PEM + * @param info optional HKDF context; the opener must pass the identical value + */ + seal(data: MessageSealInput, recipientPublicKey: string, info?: MessageSealInput): Promise; + + /** + * Open a base64 frame produced by a sender node. Returns the decrypted + * plaintext bytes; the caller decodes to string if needed. + * + * @param payload the base64 frame from `sealMessage` + * @param senderPublicKey sender node ECDH public key, hex-encoded SPKI PEM + * @param info the identical HKDF context the sealer used, if any + */ + open(payload: string, senderPublicKey: string, info?: MessageSealInput): Promise; +} diff --git a/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts b/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts new file mode 100644 index 0000000..a370021 --- /dev/null +++ b/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { randomUUID } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + CryptoAsymmetricAlgorithm, + exportAsymmetricPrivateKey, + exportAsymmetricPublicKey, +} from '@privateaim/kit'; +import { CryptoService } from '../../../../src/adapters/crypto/index.ts'; + +const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' } as const; + +async function generatePair() { + const algo = new CryptoAsymmetricAlgorithm(ECDH_PARAMS); + return algo.generateKeyPair(); +} + +async function privHex(pair: CryptoKeyPair) { + const pem = await exportAsymmetricPrivateKey(pair.privateKey); + return Buffer.from(pem, 'utf8').toString('hex'); +} + +async function pubHex(pair: CryptoKeyPair) { + const pem = await exportAsymmetricPublicKey(pair.publicKey); + return Buffer.from(pem, 'utf8').toString('hex'); +} + +/** Two configured nodes (A, B) plus their hex-encoded keys. */ +async function setup() { + const a = await generatePair(); + const b = await generatePair(); + + const aPrivHex = await privHex(a); + const aPubHex = await pubHex(a); + const bPrivHex = await privHex(b); + const bPubHex = await pubHex(b); + + const serviceA = new CryptoService({ privateKey: aPrivHex }); + const serviceB = new CryptoService({ privateKey: bPrivHex }); + + return { + aPubHex, + bPubHex, + serviceA, + serviceB, + }; +} + +describe('adapters/crypto/service', () => { + it('round-trips A->B and recovers the plaintext bytes', async () => { + const { + aPubHex, + bPubHex, + serviceA, + serviceB, + } = await setup(); + + const plaintext = `hello node-to-node ${randomUUID()}`; + + const frame = await serviceA.seal(plaintext, bPubHex); + const opened = await serviceB.open(frame, aPubHex); + + expect(typeof frame).toBe('string'); + expect(frame.length).toBeGreaterThan(0); + expect(opened).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(opened)).toBe(plaintext); + }); + + it('round-trips with raw bytes input', async () => { + const { + aPubHex, + bPubHex, + serviceA, + serviceB, + } = await setup(); + + const bytes = new TextEncoder().encode(`binary-${randomUUID()}`); + + const frame = await serviceA.seal(bytes, bPubHex); + const opened = await serviceB.open(frame, aPubHex); + + expect(Array.from(opened)).toEqual(Array.from(bytes)); + }); + + it('produces a distinct frame per seal (per-message HKDF salt) yet both open', async () => { + const { + aPubHex, + bPubHex, + serviceA, + serviceB, + } = await setup(); + + const plaintext = `repeat ${randomUUID()}`; + + const frame1 = await serviceA.seal(plaintext, bPubHex); + const frame2 = await serviceA.seal(plaintext, bPubHex); + + expect(frame1).not.toBe(frame2); + expect(new TextDecoder().decode(await serviceB.open(frame1, aPubHex))).toBe(plaintext); + expect(new TextDecoder().decode(await serviceB.open(frame2, aPubHex))).toBe(plaintext); + }); + + it('round-trips with matching info', async () => { + const { + aPubHex, + bPubHex, + serviceA, + serviceB, + } = await setup(); + + const info = `analysis:${randomUUID()}`; + const plaintext = `with-info ${randomUUID()}`; + + const frame = await serviceA.seal(plaintext, bPubHex, info); + const opened = await serviceB.open(frame, aPubHex, info); + + expect(new TextDecoder().decode(opened)).toBe(plaintext); + }); + + it('throws when info mismatches between seal and open', async () => { + const { + aPubHex, + bPubHex, + serviceA, + serviceB, + } = await setup(); + + const frame = await serviceA.seal(`msg ${randomUUID()}`, bPubHex, 'context-A'); + + await expect(serviceB.open(frame, aPubHex, 'context-B')).rejects.toThrow(); + }); + + it('throws when info present on seal but absent on open', async () => { + const { + aPubHex, + bPubHex, + serviceA, + serviceB, + } = await setup(); + + const frame = await serviceA.seal(`msg ${randomUUID()}`, bPubHex, 'some-info'); + + await expect(serviceB.open(frame, aPubHex)).rejects.toThrow(); + }); + + it('fails to open with the wrong recipient key', async () => { + const { + aPubHex, + bPubHex, + serviceA, + } = await setup(); + + const c = await generatePair(); + const serviceC = new CryptoService({ privateKey: await privHex(c) }); + + const frame = await serviceA.seal(`msg ${randomUUID()}`, bPubHex); + + await expect(serviceC.open(frame, aPubHex)).rejects.toThrow(); + }); + + it('throws when the frame salt/IV region is tampered', async () => { + const { + aPubHex, + bPubHex, + serviceA, + serviceB, + } = await setup(); + + const frame = await serviceA.seal(`msg ${randomUUID()}`, bPubHex); + + const i = Math.floor(frame.length / 2); + const swapped = frame[i] === 'A' ? 'B' : 'A'; + const tampered = frame.slice(0, i) + swapped + frame.slice(i + 1); + + await expect(serviceB.open(tampered, aPubHex)).rejects.toThrow(); + }); + + it('throws when the ciphertext/tag region is tampered', async () => { + const { + aPubHex, + bPubHex, + serviceA, + serviceB, + } = await setup(); + + const frame = await serviceA.seal(`msg ${randomUUID()}`, bPubHex); + + // last base64 char before any '=' padding lands in the GCM ciphertext/tag + const i = frame.replace(/=+$/, '').length - 1; + const swapped = frame[i] === 'A' ? 'B' : 'A'; + const tampered = frame.slice(0, i) + swapped + frame.slice(i + 1); + + await expect(serviceB.open(tampered, aPubHex)).rejects.toThrow(); + }); + + it('throws the explicit kit error for a malformed/too-short frame', async () => { + const { aPubHex, serviceB } = await setup(); + + await expect(serviceB.open('AAAA', aPubHex)).rejects.toThrow('The sealed message frame is malformed.'); + }); + + it('throws a clear error on seal when the private key is missing', async () => { + const { bPubHex } = await setup(); + const service = new CryptoService({}); + + await expect(service.seal('x', bPubHex)).rejects.toThrow(/NODE_PRIVATE_KEY is missing|not configured/i); + }); + + it('throws a clear error on open when the private key is missing', async () => { + const { + aPubHex, + bPubHex, + serviceA, + } = await setup(); + const frame = await serviceA.seal(`msg ${randomUUID()}`, bPubHex); + + const service = new CryptoService({}); + + await expect(service.open(frame, aPubHex)).rejects.toThrow(/NODE_PRIVATE_KEY is missing|not configured/i); + }); + + it('throws a clear error when the private key is not hex', async () => { + const { bPubHex } = await setup(); + const service = new CryptoService({ privateKey: 'not-hex-zz!!' }); + + await expect(service.seal('x', bPubHex)).rejects.toThrow(/valid hex/i); + }); + + it('rejects seal when the private key is hex but not a valid PEM/DER key', async () => { + const { bPubHex } = await setup(); + const service = new CryptoService({ privateKey: Buffer.from('not a pem', 'utf8').toString('hex') }); + + await expect(service.seal('x', bPubHex)).rejects.toThrow(); + }); + + it('throws a clear error when the recipient public key is not hex', async () => { + const { serviceA } = await setup(); + + await expect(serviceA.seal('x', 'zzzz-not-hex!!')).rejects.toThrow(/Peer public key.*valid hex|valid hex/i); + }); + + it('throws a clear error when the sender public key is not hex on open', async () => { + const { serviceA } = await setup(); + + await expect(serviceA.open('AAAA', 'zzzz-not-hex!!')).rejects.toThrow(/Peer public key.*valid hex|valid hex/i); + }); +}); From c2474c19376d4c67c7186158e59f9380f1c90946 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Thu, 25 Jun 2026 12:16:28 +0200 Subject: [PATCH 2/6] refactor(node-message-broker): drop third-party re-export, bound key cache - core/crypto no longer forwards `MessageSealInput` from @privateaim/kit; consumers import kit symbols directly from the package. New convention added to .agents/conventions.md: no third-party re-exports (local index.ts barrels for sibling modules stay). - CryptoService.publicKeyCache is now a bounded LRU (configurable publicKeyCacheMax, default 1024) so a long-lived node can't grow it without limit; least-recently-used peers are evicted past the cap. --- .agents/conventions.md | 4 +++ .../src/adapters/crypto/service.ts | 29 ++++++++++++++++--- .../src/core/crypto/types.ts | 2 -- .../test/unit/adapters/crypto/service.spec.ts | 28 ++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/.agents/conventions.md b/.agents/conventions.md index 1827d1e..679f9c2 100644 --- a/.agents/conventions.md +++ b/.agents/conventions.md @@ -55,6 +55,10 @@ Output: ESM (`dist/**/*.mjs`) preserving source structure + `.d.ts`. CLI entry a - **Naming**: interfaces are `I`-prefixed (`IHubClient`); types are not (`WebhookSubscription`). - **Types/interfaces** live in `types.ts` in the same directory, never inline in module files. - Prefer static imports; no dynamic imports for types. No `as any`. +- **No third-party re-exports.** Don't forward symbols from external packages through local + modules (e.g. `import type { X } from '@pkg'; export type { X };`). Import third-party + symbols directly from their source package at each use site. (Local `index.ts` barrels that + re-export sibling modules within the same directory are still fine.) ## Published Dependencies diff --git a/apps/node-message-broker/src/adapters/crypto/service.ts b/apps/node-message-broker/src/adapters/crypto/service.ts index 270a07e..c3faba4 100644 --- a/apps/node-message-broker/src/adapters/crypto/service.ts +++ b/apps/node-message-broker/src/adapters/crypto/service.ts @@ -13,12 +13,16 @@ import { openMessage, sealMessage, } from '@privateaim/kit'; -import type { ICryptoService, MessageSealInput } from '../../core/crypto/index.ts'; +import type { MessageSealInput } from '@privateaim/kit'; +import type { ICryptoService } from '../../core/crypto/index.ts'; const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' } as const; +const DEFAULT_PUBLIC_KEY_CACHE_MAX = 1024; + type CryptoServiceContext = { privateKey?: string, // hex-encoded PKCS#8 PEM (config.nodePrivateKey, OPTIONAL) + publicKeyCacheMax?: number, // max imported peer keys to retain (LRU); default 1024 }; /** @@ -36,12 +40,16 @@ export class CryptoService implements ICryptoService { private privateKeyPromise?: Promise; - // Unbounded by design, but bounded in practice by the operator-controlled set - // of peer nodes; keyed by the raw hex PEM so distinct keys never collide. - private readonly publicKeyCache = new Map>(); + protected readonly publicKeyCacheMax: number; + + // Bounded LRU keyed by the raw hex PEM (distinct keys never collide). The peer + // set is operator-controlled and small, so the cap is only a backstop against + // unbounded growth over a long-lived process. + protected readonly publicKeyCache = new Map>(); constructor(ctx: CryptoServiceContext) { this.privateKey = ctx.privateKey; + this.publicKeyCacheMax = Math.max(1, ctx.publicKeyCacheMax ?? DEFAULT_PUBLIC_KEY_CACHE_MAX); } private getPrivateKey(): Promise { @@ -73,6 +81,9 @@ export class CryptoService implements ICryptoService { private getPublicKey(hex: string): Promise { const cached = this.publicKeyCache.get(hex); if (cached) { + // re-insert to mark as most-recently-used + this.publicKeyCache.delete(hex); + this.publicKeyCache.set(hex, cached); return cached; } @@ -86,6 +97,16 @@ export class CryptoService implements ICryptoService { this.publicKeyCache.delete(hex); } }); + + // evict the least-recently-used entry (oldest insertion) past the cap + while (this.publicKeyCache.size >= this.publicKeyCacheMax) { + const oldest = this.publicKeyCache.keys().next().value; + if (oldest === undefined) { + break; + } + this.publicKeyCache.delete(oldest); + } + this.publicKeyCache.set(hex, promise); return promise; } diff --git a/apps/node-message-broker/src/core/crypto/types.ts b/apps/node-message-broker/src/core/crypto/types.ts index ead5959..f3f3176 100644 --- a/apps/node-message-broker/src/core/crypto/types.ts +++ b/apps/node-message-broker/src/core/crypto/types.ts @@ -7,8 +7,6 @@ import type { MessageSealInput } from '@privateaim/kit'; -export type { MessageSealInput }; - /** * Node-to-node end-to-end crypto port. * diff --git a/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts b/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts index a370021..81a296e 100644 --- a/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts +++ b/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts @@ -14,6 +14,13 @@ import { } from '@privateaim/kit'; import { CryptoService } from '../../../../src/adapters/crypto/index.ts'; +/** Exposes the protected cache size so the LRU bound can be asserted. */ +class InspectableCryptoService extends CryptoService { + get cacheSize() { + return this.publicKeyCache.size; + } +} + const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' } as const; async function generatePair() { @@ -250,4 +257,25 @@ describe('adapters/crypto/service', () => { await expect(serviceA.open('AAAA', 'zzzz-not-hex!!')).rejects.toThrow(/Peer public key.*valid hex|valid hex/i); }); + + it('bounds the public-key cache by evicting least-recently-used peers', async () => { + const a = await generatePair(); + const service = new InspectableCryptoService({ + privateKey: await privHex(a), + publicKeyCacheMax: 2, + }); + + const peers = [await generatePair(), await generatePair(), await generatePair()]; + for (const peer of peers) { + await service.seal('x', await pubHex(peer)); + } + + // three distinct peers were sealed to, but the cap holds + expect(service.cacheSize).toBeLessThanOrEqual(2); + + // an evicted peer is simply re-imported on next use — still seals + const frame = await service.seal('y', await pubHex(peers[0])); + expect(typeof frame).toBe('string'); + expect(service.cacheSize).toBeLessThanOrEqual(2); + }); }); From f11cebe37f53425c499488e16b111ae210e9bcae Mon Sep 17 00:00:00 2001 From: tada5hi Date: Thu, 25 Jun 2026 12:22:28 +0200 Subject: [PATCH 3/6] refactor(node-message-broker): back crypto public-key cache with lru-cache Replace the hand-rolled Map-based LRU in CryptoService with the lru-cache library; declare it as a runtime dependency. --- apps/node-message-broker/package.json | 1 + .../src/adapters/crypto/service.ts | 25 ++++++------------- package-lock.json | 8 +++++- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/apps/node-message-broker/package.json b/apps/node-message-broker/package.json index 1244f66..be270c8 100644 --- a/apps/node-message-broker/package.json +++ b/apps/node-message-broker/package.json @@ -37,6 +37,7 @@ "eldin": "^1.1.0", "envix": "^1.3.0", "hapic": "^2.8.2", + "lru-cache": "^11.5.1", "orkos": "^1.1.1", "reflect-metadata": "^0.2.2", "routup": "^6.0.0", diff --git a/apps/node-message-broker/src/adapters/crypto/service.ts b/apps/node-message-broker/src/adapters/crypto/service.ts index c3faba4..cae4dc4 100644 --- a/apps/node-message-broker/src/adapters/crypto/service.ts +++ b/apps/node-message-broker/src/adapters/crypto/service.ts @@ -14,6 +14,7 @@ import { sealMessage, } from '@privateaim/kit'; import type { MessageSealInput } from '@privateaim/kit'; +import { LRUCache } from 'lru-cache'; import type { ICryptoService } from '../../core/crypto/index.ts'; const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' } as const; @@ -40,16 +41,16 @@ export class CryptoService implements ICryptoService { private privateKeyPromise?: Promise; - protected readonly publicKeyCacheMax: number; - // Bounded LRU keyed by the raw hex PEM (distinct keys never collide). The peer // set is operator-controlled and small, so the cap is only a backstop against // unbounded growth over a long-lived process. - protected readonly publicKeyCache = new Map>(); + protected readonly publicKeyCache: LRUCache>; constructor(ctx: CryptoServiceContext) { this.privateKey = ctx.privateKey; - this.publicKeyCacheMax = Math.max(1, ctx.publicKeyCacheMax ?? DEFAULT_PUBLIC_KEY_CACHE_MAX); + + const max = Math.max(1, Math.floor(ctx.publicKeyCacheMax ?? DEFAULT_PUBLIC_KEY_CACHE_MAX)); + this.publicKeyCache = new LRUCache>({ max }); } private getPrivateKey(): Promise { @@ -81,9 +82,6 @@ export class CryptoService implements ICryptoService { private getPublicKey(hex: string): Promise { const cached = this.publicKeyCache.get(hex); if (cached) { - // re-insert to mark as most-recently-used - this.publicKeyCache.delete(hex); - this.publicKeyCache.set(hex, cached); return cached; } @@ -92,21 +90,14 @@ export class CryptoService implements ICryptoService { } const promise = importAsymmetricPublicKey(hexToUTF8(hex), ECDH_PARAMS); + // evict a rejected import (`peek` so we don't disturb LRU recency) so a + // corrected key can be retried rather than failing forever. promise.catch(() => { - if (this.publicKeyCache.get(hex) === promise) { + if (this.publicKeyCache.peek(hex) === promise) { this.publicKeyCache.delete(hex); } }); - // evict the least-recently-used entry (oldest insertion) past the cap - while (this.publicKeyCache.size >= this.publicKeyCacheMax) { - const oldest = this.publicKeyCache.keys().next().value; - if (oldest === undefined) { - break; - } - this.publicKeyCache.delete(oldest); - } - this.publicKeyCache.set(hex, promise); return promise; } diff --git a/package-lock.json b/package-lock.json index acba798..b285fd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,9 @@ "typescript": "6.0.3", "typescript-eslint": "^8.60.0", "unplugin-swc": "^1.5.9" + }, + "engines": { + "node": ">=24" } }, "apps/node-message-broker": { @@ -49,6 +52,7 @@ "eldin": "^1.1.0", "envix": "^1.3.0", "hapic": "^2.8.2", + "lru-cache": "^11.5.1", "orkos": "^1.1.1", "reflect-metadata": "^0.2.2", "routup": "^6.0.0", @@ -57,6 +61,9 @@ }, "devDependencies": { "vitest": "^4.1.7" + }, + "engines": { + "node": ">=24" } }, "apps/node-message-broker/node_modules/dotenv": { @@ -6950,7 +6957,6 @@ "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" From 879bc1cbe7c2be3adc9ade605dbb18a2a5a7d04f Mon Sep 17 00:00:00 2001 From: tada5hi Date: Thu, 25 Jun 2026 12:22:37 +0200 Subject: [PATCH 4/6] docs(node-message-broker): refresh status and layout for hub link + crypto The Hub link (REST send/pull/ack + SSE wakeup) and the end-to-end crypto adapter are now implemented; mark them done and list the remaining Phase 4 work. Add crypto to the hexagonal layout sketch. --- apps/node-message-broker/README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/node-message-broker/README.md b/apps/node-message-broker/README.md index de7397d..156bcb8 100644 --- a/apps/node-message-broker/README.md +++ b/apps/node-message-broker/README.md @@ -22,9 +22,17 @@ Durability and routing live in the **Hub** broker; this service is an encrypt/de ## Status -Scaffold. The hexagonal skeleton, config, HTTP server, and webhook-subscription CRUD -are in place. The Hub link, crypto, participant resolution, and message send/pull -routes are Phase 4 (marked with `TODO`/stub in the code). +In progress. In place: the hexagonal skeleton, config, HTTP server, +webhook-subscription CRUD + fan-out delivery, the **Hub link** (REST `send` / `pull` / +`ack` via `@privateaim/messenger-http-kit` + a reconnecting SSE wakeup stream, both +authenticated as the node client), and the **end-to-end crypto** adapter (`seal` / +`open` over `@privateaim/kit`'s `crypto/message`). + +Still open (Plan 013 Track B, Phase 4): wiring the `onWakeup` → pull → decrypt → +`delivery.deliver()` loop, the container-facing message routes (`POST /:id/messages`, +`…/broadcast`, `GET /:id/participants`, `…/participants/self`, and the additive pull +endpoint), and the analysis policy (`ANALYSIS_SELF_MESSAGE_BROKER_USE`) + participant +resolution — the `core/analysis` ports exist, but no adapter is wired yet. ## Configuration @@ -51,7 +59,7 @@ npm run cli -- start ``` src/ -├── core/ # ports — hub link, local delivery, analysis policy (no infra imports) -├── adapters/ # implementations — http controllers, hub client, delivery +├── core/ # ports — hub link, crypto, local delivery, analysis policy (no infra imports) +├── adapters/ # implementations — http controllers, hub client + SSE wakeup, crypto, delivery └── app/ # orchestration — builder, factory, DI modules (config, components, http) ``` From dfaa67645d8f17ef075a570c963d6aa9f27522a3 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Thu, 25 Jun 2026 12:25:06 +0200 Subject: [PATCH 5/6] test(node-message-broker): make crypto frame-tamper assertions deterministic Flip a whole byte on the base64-decoded frame (salt byte / GCM-tag byte) instead of a single base64 character. A 1-bit flip on the last pre-padding char can land in don't-care padding bits and decode to identical bytes, so the tamper went undetected and the test passed spuriously (flaky with random-length plaintext). --- .../test/unit/adapters/crypto/service.spec.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts b/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts index 81a296e..09f2cd1 100644 --- a/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts +++ b/apps/node-message-broker/test/unit/adapters/crypto/service.spec.ts @@ -171,39 +171,39 @@ describe('adapters/crypto/service', () => { await expect(serviceC.open(frame, aPubHex)).rejects.toThrow(); }); - it('throws when the frame salt/IV region is tampered', async () => { + it('throws when the salt/IV region is tampered', async () => { const { - aPubHex, - bPubHex, - serviceA, + aPubHex, + bPubHex, + serviceA, serviceB, } = await setup(); const frame = await serviceA.seal(`msg ${randomUUID()}`, bPubHex); - const i = Math.floor(frame.length / 2); - const swapped = frame[i] === 'A' ? 'B' : 'A'; - const tampered = frame.slice(0, i) + swapped + frame.slice(i + 1); + // flip a whole byte on the decoded frame so the change can't be lost to + // base64 padding; the first byte lives in the HKDF salt + const raw = Buffer.from(frame, 'base64'); + raw[0] ^= 0xff; - await expect(serviceB.open(tampered, aPubHex)).rejects.toThrow(); + await expect(serviceB.open(raw.toString('base64'), aPubHex)).rejects.toThrow(); }); it('throws when the ciphertext/tag region is tampered', async () => { const { - aPubHex, - bPubHex, - serviceA, + aPubHex, + bPubHex, + serviceA, serviceB, } = await setup(); const frame = await serviceA.seal(`msg ${randomUUID()}`, bPubHex); - // last base64 char before any '=' padding lands in the GCM ciphertext/tag - const i = frame.replace(/=+$/, '').length - 1; - const swapped = frame[i] === 'A' ? 'B' : 'A'; - const tampered = frame.slice(0, i) + swapped + frame.slice(i + 1); + // the last byte lives in the GCM auth tag + const raw = Buffer.from(frame, 'base64'); + raw[raw.length - 1] ^= 0xff; - await expect(serviceB.open(tampered, aPubHex)).rejects.toThrow(); + await expect(serviceB.open(raw.toString('base64'), aPubHex)).rejects.toThrow(); }); it('throws the explicit kit error for a malformed/too-short frame', async () => { From 30db2508fad6a67331886f34b28201c16ad13f40 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Thu, 25 Jun 2026 12:25:33 +0200 Subject: [PATCH 6/6] docs(node-message-broker): add branded header and license footer Mirror the Hub sub-app README convention: centered icon + title, tagline, CI/node/license badges, nav row, and a License section. --- apps/node-message-broker/README.md | 40 ++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/node-message-broker/README.md b/apps/node-message-broker/README.md index 156bcb8..1c074c2 100644 --- a/apps/node-message-broker/README.md +++ b/apps/node-message-broker/README.md @@ -1,10 +1,34 @@ -# @privateaim/node-message-broker +

+ + FLAME Node + +

-> Part of the [FLAME Node](../../README.md) monorepo — one of the node-side (data-station) -> services for the FLAME platform, alongside the central [Hub](https://github.com/PrivateAIM/hub). +

@privateaim/node-message-broker 💬

-The **node-side message broker** for the FLAME platform. It is the thin TypeScript -service that replaces the legacy Java `node-message-broker`. It owns only: +

+ The node-side message broker for the FLAME platform.
+ Container-facing REST API, end-to-end crypto, and local delivery — relaying to the Hub durable mailbox. +

+ +

+ CI + node >=24 + license +

+ +

+ Documentation  ·  + Monorepo  ·  + Hub +

+ +--- + +Part of the **[FLAME Node](https://github.com/PrivateAIM/node)** monorepo — node-side (data-station) +services for the [PrivateAIM](https://privateaim.net) platform, alongside the central [Hub](https://github.com/PrivateAIM/hub). + +A thin TypeScript service — the successor to the legacy Java `node-message-broker` — that owns only: 1. **Container-facing REST API** — the SDK-compatible surface the FLAME `flamesdk` talks to (auth: node-local Authup JWT, the analysis presents its `KEYCLOAK_TOKEN`). @@ -63,3 +87,9 @@ src/ ├── adapters/ # implementations — http controllers, hub client + SSE wakeup, crypto, delivery └── app/ # orchestration — builder, factory, DI modules (config, components, http) ``` + +## License + +Made with 💚 + +Published under [Apache 2.0](https://github.com/PrivateAIM/node/blob/master/LICENSE).