diff --git a/.gitignore b/.gitignore index ad0ac71..015bd33 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ *.tsbuildinfo .env .DS_Store +.diagrams/ +docs/private/ +scripts/gen-tldr.mjs diff --git a/package.json b/package.json index 0159039..684e4c4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "pnpm -r build", "test": "pnpm -r test", "dev": "pnpm -r --parallel dev", - "lint": "pnpm -r lint" + "lint": "pnpm -r lint", + "typecheck": "pnpm -r exec tsc --noEmit" }, "devDependencies": { "typescript": "^5.7.0", @@ -16,5 +17,10 @@ }, "engines": { "node": ">=20.0.0" + }, + "pnpm": { + "overrides": { + "serialize-javascript": ">=7.0.5" + } } } diff --git a/packages/core/src/__tests__/crypto.test.ts b/packages/core/src/__tests__/crypto.test.ts index b6c2a37..462908b 100644 --- a/packages/core/src/__tests__/crypto.test.ts +++ b/packages/core/src/__tests__/crypto.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from 'vitest'; import { encrypt, decrypt } from '../crypto/aead.js'; -import { deriveKey, advanceChain } from '../crypto/kdf.js'; +import { deriveKey, advanceChain, computeSharedSecret } from '../crypto/kdf.js'; import { sign, verify } from '../crypto/signatures.js'; import { randomBytes } from '@noble/hashes/utils'; import { ed25519 } from '@noble/curves/ed25519'; +import { generateIdentity } from '../identity.js'; describe('AEAD (XChaCha20-Poly1305)', () => { it('encrypts and decrypts roundtrip', () => { @@ -72,6 +73,33 @@ describe('KDF', () => { }); }); +describe('computeSharedSecret', () => { + it('is symmetric: both peers derive the same shared secret', () => { + const alice = generateIdentity('alice'); + const bob = generateIdentity('bob'); + const secretAB = computeSharedSecret(alice.xPrivateKey, bob.xPublicKey); + const secretBA = computeSharedSecret(bob.xPrivateKey, alice.xPublicKey); + expect(secretAB).toEqual(secretBA); + }); + + it('produces a 32-byte key', () => { + const alice = generateIdentity('alice'); + const bob = generateIdentity('bob'); + const secret = computeSharedSecret(alice.xPrivateKey, bob.xPublicKey); + expect(secret.length).toBe(32); + expect(secret).toBeInstanceOf(Uint8Array); + }); + + it('produces different secrets for different peer pairs', () => { + const alice = generateIdentity('alice'); + const bob = generateIdentity('bob'); + const carol = generateIdentity('carol'); + const secretAB = computeSharedSecret(alice.xPrivateKey, bob.xPublicKey); + const secretAC = computeSharedSecret(alice.xPrivateKey, carol.xPublicKey); + expect(secretAB).not.toEqual(secretAC); + }); +}); + describe('Signatures', () => { it('sign and verify roundtrip', () => { const privateKey = randomBytes(32); diff --git a/packages/core/src/__tests__/messages.test.ts b/packages/core/src/__tests__/messages.test.ts index 2a61897..0927683 100644 --- a/packages/core/src/__tests__/messages.test.ts +++ b/packages/core/src/__tests__/messages.test.ts @@ -18,6 +18,7 @@ describe('messages (CBOR encoding)', () => { const sampleHandshake: ProtocolMessage = { type: MessageType.IdentityHandshake, edPublicKey: new Uint8Array(32).fill(1), + xPublicKey: new Uint8Array(32).fill(4), noisePublicKey: new Uint8Array(32).fill(2), signature: new Uint8Array(64).fill(3), displayName: 'agent-1', diff --git a/packages/core/src/crypto/index.ts b/packages/core/src/crypto/index.ts index 9462ee6..c15c645 100644 --- a/packages/core/src/crypto/index.ts +++ b/packages/core/src/crypto/index.ts @@ -1,3 +1,3 @@ export { encrypt, decrypt } from './aead.js'; -export { deriveKey, advanceChain } from './kdf.js'; +export { deriveKey, advanceChain, computeSharedSecret } from './kdf.js'; export { sign, verify } from './signatures.js'; diff --git a/packages/core/src/crypto/kdf.ts b/packages/core/src/crypto/kdf.ts index 2dfdf2a..0710a48 100644 --- a/packages/core/src/crypto/kdf.ts +++ b/packages/core/src/crypto/kdf.ts @@ -1,5 +1,6 @@ import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha256'; +import { x25519 } from '@noble/curves/ed25519'; export function deriveKey( ikm: Uint8Array, @@ -10,6 +11,18 @@ export function deriveKey( return hkdf(sha256, ikm, salt, info, length); } +/** + * Compute X25519 ECDH shared secret between two peers and derive + * a root key suitable for Double Ratchet initialization. + */ +export function computeSharedSecret( + myXPrivateKey: Uint8Array, + peerXPublicKey: Uint8Array +): Uint8Array { + const rawSharedSecret = x25519.getSharedSecret(myXPrivateKey, peerXPublicKey); + return deriveKey(rawSharedSecret, 'networkselfmd-dm-v1', '', 32); +} + export function advanceChain(chainKey: Uint8Array): { messageKey: Uint8Array; nextChainKey: Uint8Array; diff --git a/packages/core/src/protocol/types.ts b/packages/core/src/protocol/types.ts index 376c0c5..490d1bc 100644 --- a/packages/core/src/protocol/types.ts +++ b/packages/core/src/protocol/types.ts @@ -16,6 +16,7 @@ export type MessageTypeValue = (typeof MessageType)[keyof typeof MessageType]; export interface IdentityHandshakeMessage { type: typeof MessageType.IdentityHandshake; edPublicKey: Uint8Array; + xPublicKey: Uint8Array; noisePublicKey: Uint8Array; signature: Uint8Array; displayName?: string; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 1a55005..7e4fd65 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@fastify/cors": "^10.0.0", - "@fastify/static": "^8.1.0", + "@fastify/static": "^9.1.1", "@networkselfmd/node": "workspace:*", "fastify": "^5.2.0", "react": "^18.3.0", @@ -20,11 +20,14 @@ }, "devDependencies": { "@paper-design/shaders-react": "^0.0.76", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^22.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", "concurrently": "^9.1.0", + "jsdom": "^29.1.0", "tsx": "^4.19.0", "typescript": "^5.7.0", "vite": "^6.3.0", diff --git a/packages/dashboard/src/client/__tests__/useRoute.test.ts b/packages/dashboard/src/client/__tests__/useRoute.test.ts new file mode 100644 index 0000000..742143a --- /dev/null +++ b/packages/dashboard/src/client/__tests__/useRoute.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useRoute } from '../hooks/useRoute.js'; + +describe('useRoute', () => { + afterEach(() => { + window.location.hash = ''; + }); + + it('defaults to home when hash is empty', () => { + window.location.hash = ''; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'home' }); + }); + + it('parses /discover', () => { + window.location.hash = '#/discover'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'discover' }); + }); + + it('parses /wire', () => { + window.location.hash = '#/wire'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'wire' }); + }); + + it('parses /security', () => { + window.location.hash = '#/security'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'security' }); + }); + + it('parses /settings', () => { + window.location.hash = '#/settings'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'settings' }); + }); + + it('parses /state/:id', () => { + window.location.hash = '#/state/abc123'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'state', stateId: 'abc123' }); + }); + + it('parses /states/:id (plural form)', () => { + window.location.hash = '#/states/def456'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'state', stateId: 'def456' }); + }); + + it('decodes percent-encoded state id', () => { + window.location.hash = '#/state/hello%20world'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'state', stateId: 'hello world' }); + }); + + it('falls back to home for unknown routes', () => { + window.location.hash = '#/unknown-page'; + const { result } = renderHook(() => useRoute()); + expect(result.current).toEqual({ page: 'home' }); + }); + + it('responds to hashchange events', () => { + window.location.hash = '#/'; + const { result } = renderHook(() => useRoute()); + expect(result.current.page).toBe('home'); + + act(() => { + window.location.hash = '#/discover'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + expect(result.current).toEqual({ page: 'discover' }); + }); +}); diff --git a/packages/dashboard/src/server/__tests__/routes.test.ts b/packages/dashboard/src/server/__tests__/routes.test.ts index ed417c4..30e902d 100644 --- a/packages/dashboard/src/server/__tests__/routes.test.ts +++ b/packages/dashboard/src/server/__tests__/routes.test.ts @@ -61,8 +61,28 @@ function mockAgent() { }, ], joinPublicGroup: async () => { joinedPublic = true; }, - getGroupMembers: () => [], - getMessages: () => [], + getGroupMembers: (id: string) => { + if (id === '010203') { + return [ + { fingerprint: 'member1fp', displayName: 'Alice', role: 'admin' }, + { fingerprint: 'member2fp', displayName: undefined, role: 'member' }, + ]; + } + return []; + }, + getMessages: ({ groupId }: { groupId: string; limit: number }) => { + if (groupId === '010203') { + return [ + { + id: 'msg1', + senderPublicKey: new Uint8Array(32).fill(1), + content: 'hello builders', + timestamp: now, + }, + ]; + } + return []; + }, }; } @@ -160,4 +180,46 @@ describe('Dashboard API routes', () => { const statesRes = await app.inject({ method: 'GET', url: '/api/states' }); expect(statesRes.payload).not.toContain('secret'); }); + + // --- Identity endpoints --- + + it('GET /api/identity returns fingerprint and displayName', async () => { + const res = await app.inject({ method: 'GET', url: '/api/identity' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.fingerprint).toBe('abc123'); + expect(body.displayName).toBe('TestAgent'); + }); + + // --- State detail endpoint --- + + it('GET /api/states/:id returns detail for an own group', async () => { + const res = await app.inject({ method: 'GET', url: '/api/states/010203' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.name).toBe('builders'); + expect(body.members).toHaveLength(2); + expect(body.members[0].role).toBe('admin'); + expect(body.messages).toHaveLength(1); + expect(body.messages[0].content).toBe('hello builders'); + }); + + it('GET /api/states/:id returns 404 for unknown id', async () => { + const res = await app.inject({ method: 'GET', url: '/api/states/ffffff' }); + expect(res.statusCode).toBe(404); + }); + + // --- 501 stub endpoints --- + + it('GET /api/wire/events returns 501', async () => { + const res = await app.inject({ method: 'GET', url: '/api/wire/events' }); + expect(res.statusCode).toBe(501); + expect(res.json().error.code).toBe('wire-trace-unavailable'); + }); + + it('GET /api/security/keys returns 501', async () => { + const res = await app.inject({ method: 'GET', url: '/api/security/keys' }); + expect(res.statusCode).toBe(501); + expect(res.json().error.code).toBe('security-keys-unavailable'); + }); }); diff --git a/packages/dashboard/src/server/routes.ts b/packages/dashboard/src/server/routes.ts index 6905a31..aaedc9b 100644 --- a/packages/dashboard/src/server/routes.ts +++ b/packages/dashboard/src/server/routes.ts @@ -15,7 +15,7 @@ function errorMessage(err: unknown): string { const LOCAL_ORIGIN_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); function isAllowedLocalOrigin(origin: string | undefined): boolean { - if (!origin) return true; + if (!origin) return false; try { const url = new URL(origin); return (url.protocol === 'http:' || url.protocol === 'https:') && LOCAL_ORIGIN_HOSTS.has(url.hostname); @@ -29,8 +29,18 @@ function originHeader(request: FastifyRequest): string | undefined { return Array.isArray(origin) ? origin[0] : origin; } +const LOCALHOST_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); + async function requireLocalMutationOrigin(request: FastifyRequest, reply: FastifyReply): Promise { - if (!isAllowedLocalOrigin(originHeader(request))) { + const origin = originHeader(request); + if (origin) { + if (!isAllowedLocalOrigin(origin)) { + await reply.status(403).send({ error: { code: 'forbidden-origin', message: 'mutations require a localhost origin' } }); + } + return; + } + // No Origin header (non-browser client) — verify the request comes from localhost + if (!request.ip || !LOCALHOST_IPS.has(request.ip)) { await reply.status(403).send({ error: { code: 'forbidden-origin', message: 'mutations require a localhost origin' } }); } } @@ -161,12 +171,16 @@ export async function buildApp({ agent }: DashboardAgent) { } try { - await agent.joinPublicGroup(id); + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('Join group timed out')), 30_000) + ); + await Promise.race([agent.joinPublicGroup(id), timeout]); const state = getMergedStates().find((s) => s.id === id || s.name === discovered.name) ?? discovered; return { ok: true, state }; } catch (err) { + console.error('[API Error]', errorMessage(err)); reply.status(502); - return { ok: false, reason: 'unreachable', message: errorMessage(err) }; + return { ok: false, reason: 'unreachable', message: 'Failed to join group' }; } }); diff --git a/packages/node/src/__tests__/agent.test.ts b/packages/node/src/__tests__/agent.test.ts index 41c763e..19b0296 100644 --- a/packages/node/src/__tests__/agent.test.ts +++ b/packages/node/src/__tests__/agent.test.ts @@ -165,4 +165,36 @@ describe('Agent', () => { await agent.stop(); }); + + it('should not crash on emitted error events', () => { + const agent = new Agent({ dataDir }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // This would crash the process without the default error listener + expect(() => { + agent.emit('error', new Error('peer handshake failed')); + }).not.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[Agent error]', + 'peer handshake failed', + ); + + consoleSpy.mockRestore(); + }); + + it('should still deliver errors to custom listeners', () => { + const agent = new Agent({ dataDir }); + const errors: Error[] = []; + + agent.on('error', (err: Error) => { + errors.push(err); + }); + + agent.emit('error', new Error('swarm connection lost')); + + // Custom listener received it + expect(errors.length).toBe(1); + expect(errors[0].message).toBe('swarm connection lost'); + }); }); diff --git a/packages/node/src/__tests__/storage.test.ts b/packages/node/src/__tests__/storage.test.ts index 9dfb59f..4f41df9 100644 --- a/packages/node/src/__tests__/storage.test.ts +++ b/packages/node/src/__tests__/storage.test.ts @@ -52,7 +52,7 @@ describe('AgentDatabase', () => { const row = db .prepare('SELECT version FROM schema_version') .get() as { version: number }; - expect(row.version).toBe(2); + expect(row.version).toBe(3); }); }); diff --git a/packages/node/src/agent.ts b/packages/node/src/agent.ts index d293967..93b0e02 100644 --- a/packages/node/src/agent.ts +++ b/packages/node/src/agent.ts @@ -8,6 +8,8 @@ import { deriveKey, sign, verify, + computeSharedSecret, + DoubleRatchet, } from '@networkselfmd/core'; import type { AgentIdentity, @@ -19,6 +21,7 @@ import type { GroupEncryptedMessage, GroupManagementMessage, NetworkAnnounceMessage, + DoubleRatchetState, } from '@networkselfmd/core'; import { MessageType } from '@networkselfmd/core'; import { createId } from '@paralleldrive/cuid2'; @@ -30,6 +33,7 @@ import { MessageRepository, SenderKeyRepository, DiscoveredGroupRepository, + RatchetStateRepository, } from './storage/index.js'; import { SwarmManager } from './network/swarm.js'; import type { PeerSession } from './network/connection.js'; @@ -74,12 +78,19 @@ export class Agent extends EventEmitter { private messageRepo!: MessageRepository; private senderKeyRepo!: SenderKeyRepository; private discoveredGroupRepo!: DiscoveredGroupRepository; + private ratchetStateRepo!: RatchetStateRepository; private swarm!: SwarmManager; private groupManager!: GroupManager; constructor(options: AgentOptions) { super(); this.options = options; + + // Prevent unhandled 'error' events from crashing the process. + // Node.js EventEmitter kills the process if 'error' is emitted with no listener. + this.on('error', (err: Error) => { + console.error('[Agent error]', err.message); + }); } async start(): Promise { @@ -96,6 +107,7 @@ export class Agent extends EventEmitter { this.messageRepo = new MessageRepository(db); this.senderKeyRepo = new SenderKeyRepository(db); this.discoveredGroupRepo = new DiscoveredGroupRepository(db); + this.ratchetStateRepo = new RatchetStateRepository(db); // Load or generate identity await this.loadOrGenerateIdentity(); @@ -262,17 +274,32 @@ export class Agent extends EventEmitter { const plaintext = new TextEncoder().encode(content); - // In production, use DoubleRatchet for encryption - const encrypted = encrypt(this.identity.edPrivateKey.subarray(0, 32), plaintext); + // Load or initialize Double Ratchet state for this peer + let ratchetState = this.ratchetStateRepo.load(peerFingerprint); + + if (!ratchetState) { + // First message to this peer — initialize as sender + if (!session.peerXPublicKey) { + throw new Error('Peer X25519 public key not available for DM encryption'); + } + const sharedSecret = computeSharedSecret(this.identity.xPrivateKey, session.peerXPublicKey); + ratchetState = DoubleRatchet.initSender(sharedSecret, session.peerXPublicKey); + } + + // Encrypt with Double Ratchet + const encrypted = DoubleRatchet.encrypt(ratchetState, plaintext); + + // Save updated ratchet state + this.ratchetStateRepo.save(peerFingerprint, encrypted.nextState); const messageId = createId(); const message: ProtocolMessage = { type: MessageType.DirectMessage, senderFingerprint: this.identity.fingerprint, recipientFingerprint: peerFingerprint, - ratchetPublicKey: this.identity.edPublicKey, - previousChainLength: 0, - messageNumber: 0, + ratchetPublicKey: encrypted.ratchetPublicKey, + previousChainLength: encrypted.previousChainLength, + messageNumber: encrypted.messageNumber, ciphertext: encrypted.ciphertext, nonce: encrypted.nonce, timestamp: Date.now(), @@ -608,13 +635,34 @@ export class Agent extends EventEmitter { session: PeerSession, message: DirectEncryptedMessage, ): void { - if (!session.peerPublicKey) return; + if (!session.peerPublicKey || !session.peerFingerprint) return; - // Decrypt the message (simplified - in production use DoubleRatchet) - let plaintext: Uint8Array; + const senderFingerprint = session.peerFingerprint; + + // Load or initialize Double Ratchet state for this peer + let ratchetState = this.ratchetStateRepo.load(senderFingerprint); + + if (!ratchetState) { + // First message from this peer — initialize as receiver + if (!session.peerXPublicKey) { + this.emit('error', new Error('Peer X25519 public key not available for DM decryption')); + return; + } + const sharedSecret = computeSharedSecret(this.identity.xPrivateKey, session.peerXPublicKey); + ratchetState = DoubleRatchet.initReceiver(sharedSecret, { + privateKey: this.identity.xPrivateKey, + publicKey: this.identity.xPublicKey, + }); + } + + // Decrypt with Double Ratchet + let decrypted: { plaintext: Uint8Array; nextState: DoubleRatchetState }; try { - plaintext = decrypt( - session.peerPublicKey.subarray(0, 32), + decrypted = DoubleRatchet.decrypt( + ratchetState, + message.ratchetPublicKey, + message.previousChainLength, + message.messageNumber, message.nonce, message.ciphertext, ); @@ -623,7 +671,10 @@ export class Agent extends EventEmitter { return; } - const content = new TextDecoder().decode(plaintext); + // Save updated ratchet state + this.ratchetStateRepo.save(senderFingerprint, decrypted.nextState); + + const content = new TextDecoder().decode(decrypted.plaintext); this.messageRepo.insert({ id: createId(), diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 9110bfa..b7ef461 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -9,6 +9,7 @@ export { MessageRepository, SenderKeyRepository, DiscoveredGroupRepository, + RatchetStateRepository, } from './storage/repositories.js'; export { PeerSession } from './network/connection.js'; diff --git a/packages/node/src/network/connection.ts b/packages/node/src/network/connection.ts index dab4edc..0243a04 100644 --- a/packages/node/src/network/connection.ts +++ b/packages/node/src/network/connection.ts @@ -10,6 +10,7 @@ export type ConnectionState = 'connecting' | 'handshaking' | 'verified' | 'ready export class PeerSession extends EventEmitter { state: ConnectionState = 'connecting'; peerPublicKey: Uint8Array | null = null; + peerXPublicKey: Uint8Array | null = null; peerFingerprint: string | null = null; peerDisplayName: string | null = null; noisePublicKey: Uint8Array | null = null; @@ -108,8 +109,10 @@ export class PeerSession extends EventEmitter { peerPublicKey: Uint8Array, peerFingerprint: string, peerDisplayName?: string, + peerXPublicKey?: Uint8Array, ): void { this.peerPublicKey = peerPublicKey; + this.peerXPublicKey = peerXPublicKey ?? null; this.peerFingerprint = peerFingerprint; this.peerDisplayName = peerDisplayName ?? null; this.state = 'verified'; diff --git a/packages/node/src/network/handshake.ts b/packages/node/src/network/handshake.ts index 497369f..b60419f 100644 --- a/packages/node/src/network/handshake.ts +++ b/packages/node/src/network/handshake.ts @@ -28,17 +28,19 @@ export async function performHandshake( const noisePublicKey = session.noisePublicKey ?? new Uint8Array(32); const timestamp = Date.now(); - // Build signing payload: noisePublicKey || timestamp as uint64 BE - const payload = new Uint8Array(noisePublicKey.length + 8); + // Build signing payload: noisePublicKey || xPublicKey || timestamp as uint64 BE + const payload = new Uint8Array(noisePublicKey.length + identity.xPublicKey.length + 8); payload.set(noisePublicKey, 0); + payload.set(identity.xPublicKey, noisePublicKey.length); const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); - view.setBigUint64(noisePublicKey.length, BigInt(timestamp), false); + view.setBigUint64(noisePublicKey.length + identity.xPublicKey.length, BigInt(timestamp), false); const signature = sign(payload, identity.edPrivateKey); const handshakeMessage: ProtocolMessage = { type: MessageType.IdentityHandshake, edPublicKey: identity.edPublicKey, + xPublicKey: identity.xPublicKey, noisePublicKey: noisePublicKey, signature, protocolVersion: 1, @@ -91,6 +93,7 @@ export async function performHandshake( peerHandshake.edPublicKey, peerFingerprint, peerHandshake.displayName, + peerHandshake.xPublicKey, ); // Use queueMicrotask to resolve after the current synchronous @@ -140,13 +143,14 @@ function validateHandshake( } // Reconstruct the payload the peer signed: - // They signed their own view of noisePublicKey (the remote noise key they see) - // which they included in the handshake message. + // noisePublicKey || xPublicKey || timestamp as uint64 BE const noiseKey = handshake.noisePublicKey; - const payload = new Uint8Array(noiseKey.length + 8); + const xKey = handshake.xPublicKey; + const payload = new Uint8Array(noiseKey.length + xKey.length + 8); payload.set(noiseKey, 0); + payload.set(xKey, noiseKey.length); const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); - view.setBigUint64(noiseKey.length, BigInt(handshake.timestamp), false); + view.setBigUint64(noiseKey.length + xKey.length, BigInt(handshake.timestamp), false); const valid = verify(handshake.signature, payload, handshake.edPublicKey); if (!valid) { diff --git a/packages/node/src/storage/database.ts b/packages/node/src/storage/database.ts index ec8d592..6722290 100644 --- a/packages/node/src/storage/database.ts +++ b/packages/node/src/storage/database.ts @@ -1,8 +1,8 @@ import Database from 'better-sqlite3'; import { join } from 'node:path'; -import { mkdirSync, existsSync } from 'node:fs'; +import { mkdirSync, existsSync, chmodSync } from 'node:fs'; -const SCHEMA_VERSION = 2; +const SCHEMA_VERSION = 3; const MIGRATIONS: string[] = [ ` @@ -83,6 +83,15 @@ const MIGRATIONS: string[] = [ UPDATE schema_version SET version = 2; `, + ` + CREATE TABLE IF NOT EXISTS dm_ratchet_states ( + peer_fingerprint TEXT PRIMARY KEY, + state_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + UPDATE schema_version SET version = 3; + `, ]; export class AgentDatabase { @@ -90,10 +99,13 @@ export class AgentDatabase { constructor(dataDir: string) { if (!existsSync(dataDir)) { - mkdirSync(dataDir, { recursive: true }); + mkdirSync(dataDir, { recursive: true, mode: 0o700 }); } const dbPath = join(dataDir, 'agent.db'); this.db = new Database(dbPath); + if (process.platform !== 'win32') { + chmodSync(dbPath, 0o600); + } this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); } diff --git a/packages/node/src/storage/index.ts b/packages/node/src/storage/index.ts index 1d4f3f1..7fcce50 100644 --- a/packages/node/src/storage/index.ts +++ b/packages/node/src/storage/index.ts @@ -6,6 +6,7 @@ export { MessageRepository, SenderKeyRepository, DiscoveredGroupRepository, + RatchetStateRepository, } from './repositories.js'; export type { StoredIdentity, diff --git a/packages/node/src/storage/repositories.ts b/packages/node/src/storage/repositories.ts index 869deb6..32351ca 100644 --- a/packages/node/src/storage/repositories.ts +++ b/packages/node/src/storage/repositories.ts @@ -1,4 +1,5 @@ import type Database from 'better-sqlite3'; +import type { DoubleRatchetState } from '@networkselfmd/core'; // Local types for DB rows export interface StoredIdentity { @@ -367,3 +368,90 @@ export class SenderKeyRepository { .run(Buffer.from(groupId)); } } + +// --- Hex encoding helpers for Uint8Array serialization --- + +function toHex(arr: Uint8Array): string { + return Array.from(arr) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function fromHex(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +interface SerializedRatchetState { + rootKey: string; + sendChainKey: string | null; + receiveChainKey: string | null; + sendRatchetPrivate: string; + sendRatchetPublic: string; + receiveRatchetPublic: string | null; + sendMessageNumber: number; + receiveMessageNumber: number; + previousChainLength: number; + skippedKeys: Array<[string, string]>; +} + +function serializeRatchetState(state: DoubleRatchetState): string { + const serialized: SerializedRatchetState = { + rootKey: toHex(state.rootKey), + sendChainKey: state.sendChainKey ? toHex(state.sendChainKey) : null, + receiveChainKey: state.receiveChainKey ? toHex(state.receiveChainKey) : null, + sendRatchetPrivate: toHex(state.sendRatchetPrivate), + sendRatchetPublic: toHex(state.sendRatchetPublic), + receiveRatchetPublic: state.receiveRatchetPublic ? toHex(state.receiveRatchetPublic) : null, + sendMessageNumber: state.sendMessageNumber, + receiveMessageNumber: state.receiveMessageNumber, + previousChainLength: state.previousChainLength, + skippedKeys: Array.from(state.skippedKeys.entries()).map(([k, v]) => [k, toHex(v)]), + }; + return JSON.stringify(serialized); +} + +function deserializeRatchetState(json: string): DoubleRatchetState { + const s: SerializedRatchetState = JSON.parse(json); + return { + rootKey: fromHex(s.rootKey), + sendChainKey: s.sendChainKey ? fromHex(s.sendChainKey) : null, + receiveChainKey: s.receiveChainKey ? fromHex(s.receiveChainKey) : null, + sendRatchetPrivate: fromHex(s.sendRatchetPrivate), + sendRatchetPublic: fromHex(s.sendRatchetPublic), + receiveRatchetPublic: s.receiveRatchetPublic ? fromHex(s.receiveRatchetPublic) : null, + sendMessageNumber: s.sendMessageNumber, + receiveMessageNumber: s.receiveMessageNumber, + previousChainLength: s.previousChainLength, + skippedKeys: new Map(s.skippedKeys.map(([k, v]) => [k, fromHex(v)])), + }; +} + +export class RatchetStateRepository { + constructor(private db: Database.Database) {} + + save(peerFingerprint: string, state: DoubleRatchetState): void { + const stmt = this.db.prepare( + `INSERT OR REPLACE INTO dm_ratchet_states (peer_fingerprint, state_json, updated_at) + VALUES (?, ?, ?)`, + ); + stmt.run(peerFingerprint, serializeRatchetState(state), Date.now()); + } + + load(peerFingerprint: string): DoubleRatchetState | null { + const row = this.db + .prepare('SELECT state_json FROM dm_ratchet_states WHERE peer_fingerprint = ?') + .get(peerFingerprint) as { state_json: string } | undefined; + if (!row) return null; + return deserializeRatchetState(row.state_json); + } + + delete(peerFingerprint: string): void { + this.db + .prepare('DELETE FROM dm_ratchet_states WHERE peer_fingerprint = ?') + .run(peerFingerprint); + } +} diff --git a/packages/node/src/ttya/ttya-manager.ts b/packages/node/src/ttya/ttya-manager.ts new file mode 100644 index 0000000..0f5b32c --- /dev/null +++ b/packages/node/src/ttya/ttya-manager.ts @@ -0,0 +1,365 @@ +import { EventEmitter } from 'node:events'; +import Hyperswarm from 'hyperswarm'; +import { deriveKey, verify } from '@networkselfmd/core'; + +/** TTYA request sent from web bridge to agent node via Hyperswarm */ +export interface TTYARequest { + type: 0x07; + visitorId: string; + action: 'message' | 'connect' | 'disconnect'; + content?: string; + metadata: { + ipHash: string; + userAgent?: string; + timestamp: number; + }; +} + +/** TTYA response sent from agent node to web bridge via Hyperswarm */ +export interface TTYAResponse { + type: 0x08; + visitorId: string; + action: 'approve' | 'reject' | 'reply'; + content?: string; + sessionToken?: string; +} + +export interface TTYAVisitor { + visitorId: string; + firstMessage: string; + ipHash: string; + timestamp: number; + status: 'pending' | 'approved' | 'rejected'; +} + +/** Maximum clock skew allowed for auth frame timestamps (5 minutes) */ +const AUTH_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; + +/** Time to wait for auth frame before disconnecting (5 seconds) */ +const AUTH_TIMEOUT_MS = 5_000; + +/** Auth frame sent by bridge as first message */ +interface TTYAAuthFrame { + type: 'ttya-auth'; + bridgePublicKey: string; + timestamp: number; + signature: string; +} + +function isValidAuthFrame(obj: unknown): obj is TTYAAuthFrame { + if (obj === null || typeof obj !== 'object') return false; + const o = obj as Record; + return ( + o.type === 'ttya-auth' && + typeof o.bridgePublicKey === 'string' && + typeof o.timestamp === 'number' && + typeof o.signature === 'string' + ); +} + +/** Maximum allowed TTYA frame payload size (64 KB). Prevents OOM from malicious peers. */ +const MAX_TTYA_FRAME_SIZE = 65536; + +const VALID_REQUEST_ACTIONS = new Set(['message', 'connect', 'disconnect']); + +/** + * Runtime validation for TTYARequest objects received over the wire. + * Rejects messages with missing or wrong-type fields to prevent crashes + * from malicious Hyperswarm peers. + */ +function isValidTTYARequest(obj: unknown): obj is TTYARequest { + if (obj === null || typeof obj !== 'object') return false; + const o = obj as Record; + if (o.type !== 0x07) return false; + if (typeof o.visitorId !== 'string') return false; + if (typeof o.action !== 'string' || !VALID_REQUEST_ACTIONS.has(o.action)) return false; + if (o.content !== undefined && typeof o.content !== 'string') return false; + if (o.metadata === null || typeof o.metadata !== 'object') return false; + const meta = o.metadata as Record; + if (typeof meta.ipHash !== 'string') return false; + if (typeof meta.timestamp !== 'number') return false; + if (meta.userAgent !== undefined && typeof meta.userAgent !== 'string') return false; + return true; +} + +function encodeFrame(msg: TTYAResponse): Buffer { + const json = JSON.stringify(msg); + const payload = Buffer.from(json, 'utf-8'); + const frame = Buffer.alloc(4 + payload.length); + frame.writeUInt32BE(payload.length, 0); + payload.copy(frame, 4); + return frame; +} + +function decodeFrames(data: Buffer): { requests: TTYARequest[]; consumed: number } { + const requests: TTYARequest[] = []; + let offset = 0; + + while (offset + 4 <= data.length) { + const len = data.readUInt32BE(offset); + if (len > MAX_TTYA_FRAME_SIZE) { + throw new Error(`TTYA frame too large: ${len} bytes (max ${MAX_TTYA_FRAME_SIZE})`); + } + if (offset + 4 + len > data.length) break; + const payload = data.subarray(offset + 4, offset + 4 + len); + try { + const parsed: unknown = JSON.parse(payload.toString('utf-8')); + if (isValidTTYARequest(parsed)) { + requests.push(parsed); + } else { + console.warn('[TTYAManager] Skipping invalid request frame: failed validation'); + } + } catch { + // skip malformed frames + } + offset += 4 + len; + } + + return { requests, consumed: offset }; +} + +export class TTYAManager extends EventEmitter { + private edPublicKey: Uint8Array; + private swarm: Hyperswarm | null = null; + private bridgeConnection: any = null; + private receiveBuffer = Buffer.alloc(0); + private visitors = new Map(); + private authenticated = false; + private bridgePublicKey: Uint8Array | null = null; + private authTimeout: ReturnType | null = null; + isRunning = false; + + constructor(edPublicKey: Uint8Array) { + super(); + this.edPublicKey = edPublicKey; + } + + async start(): Promise { + if (this.isRunning) return; + + this.swarm = new Hyperswarm(); + + this.swarm.on('connection', (conn: any, _info: any) => { + this.bridgeConnection = conn; + this.receiveBuffer = Buffer.alloc(0); + this.authenticated = false; + this.bridgePublicKey = null; + + // Require auth frame within AUTH_TIMEOUT_MS + this.authTimeout = setTimeout(() => { + if (!this.authenticated && this.bridgeConnection === conn) { + console.warn('[TTYAManager] Auth timeout — destroying connection'); + conn.destroy(); + } + }, AUTH_TIMEOUT_MS); + + conn.on('data', (chunk: Buffer) => { + this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]); + + if (this.receiveBuffer.length > MAX_TTYA_FRAME_SIZE + 4) { + console.warn(`[TTYAManager] Receive buffer exceeded max size (${this.receiveBuffer.length} bytes), destroying connection`); + this.receiveBuffer = Buffer.alloc(0); + conn.destroy(); + return; + } + + this.processBuffer(conn); + }); + + conn.on('close', () => { + this.clearAuthTimeout(); + this.bridgeConnection = null; + this.authenticated = false; + this.bridgePublicKey = null; + this.receiveBuffer = Buffer.alloc(0); + }); + + conn.on('error', () => { + this.clearAuthTimeout(); + this.bridgeConnection = null; + this.authenticated = false; + this.bridgePublicKey = null; + this.receiveBuffer = Buffer.alloc(0); + }); + }); + + const topic = deriveKey(this.edPublicKey, 'networkselfmd-ttya-v1', '', 32); + const discovery = this.swarm.join(Buffer.from(topic), { server: true, client: true }); + await discovery.flushed(); + this.isRunning = true; + } + + async stop(): Promise { + if (!this.isRunning) return; + this.isRunning = false; + + this.clearAuthTimeout(); + + if (this.bridgeConnection) { + try { + this.bridgeConnection.destroy(); + } catch { + /* ignore */ + } + this.bridgeConnection = null; + } + + if (this.swarm) { + await this.swarm.destroy(); + this.swarm = null; + } + + this.visitors.clear(); + this.authenticated = false; + this.bridgePublicKey = null; + this.receiveBuffer = Buffer.alloc(0); + } + + getPending(): TTYAVisitor[] { + return Array.from(this.visitors.values()).filter((v) => v.status === 'pending'); + } + + approve(visitorId: string): void { + const visitor = this.visitors.get(visitorId); + if (!visitor) throw new Error('Unknown visitor: ' + visitorId); + visitor.status = 'approved'; + this.sendResponse({ type: 0x08, visitorId, action: 'approve' }); + } + + reject(visitorId: string): void { + const visitor = this.visitors.get(visitorId); + if (!visitor) throw new Error('Unknown visitor: ' + visitorId); + visitor.status = 'rejected'; + this.visitors.delete(visitorId); + this.sendResponse({ type: 0x08, visitorId, action: 'reject' }); + } + + reply(visitorId: string, content: string): void { + const visitor = this.visitors.get(visitorId); + if (!visitor) throw new Error('Unknown visitor: ' + visitorId); + this.sendResponse({ type: 0x08, visitorId, action: 'reply', content }); + } + + private sendResponse(response: TTYAResponse): void { + if (!this.bridgeConnection) return; + try { + this.bridgeConnection.write(encodeFrame(response)); + } catch { + // connection may have dropped + } + } + + private clearAuthTimeout(): void { + if (this.authTimeout) { + clearTimeout(this.authTimeout); + this.authTimeout = null; + } + } + + private processBuffer(conn?: any): void { + // If not authenticated, expect the first frame to be an auth frame + if (!this.authenticated) { + // Need at least 4 bytes for the length prefix + if (this.receiveBuffer.length < 4) return; + const len = this.receiveBuffer.readUInt32BE(0); + if (this.receiveBuffer.length < 4 + len) return; + + const payload = this.receiveBuffer.subarray(4, 4 + len); + this.receiveBuffer = Buffer.from(this.receiveBuffer.subarray(4 + len)); + + try { + const parsed: unknown = JSON.parse(payload.toString('utf-8')); + if (isValidAuthFrame(parsed)) { + if (this.verifyAuthFrame(parsed)) { + this.authenticated = true; + this.bridgePublicKey = Buffer.from(parsed.bridgePublicKey, 'hex'); + this.clearAuthTimeout(); + // Continue processing any remaining data in the buffer + if (this.receiveBuffer.length > 0) { + this.processBuffer(conn); + } + return; + } + } + } catch { + // malformed frame + } + + // Auth failed — destroy connection + console.warn('[TTYAManager] Auth failed — destroying connection'); + const target = conn || this.bridgeConnection; + if (target) { + try { target.destroy(); } catch { /* ignore */ } + } + return; + } + + // Authenticated — process TTYA requests normally + let requests: TTYARequest[]; + let consumed: number; + try { + ({ requests, consumed } = decodeFrames(this.receiveBuffer)); + } catch (err) { + console.warn('[TTYAManager] Frame decode error, clearing buffer:', err); + this.receiveBuffer = Buffer.alloc(0); + const target = conn || this.bridgeConnection; + if (target) { try { target.destroy(); } catch { /* ignore */ } } + return; + } + if (requests.length === 0) return; + + this.receiveBuffer = Buffer.from(this.receiveBuffer.subarray(consumed)); + + for (const req of requests) { + this.handleRequest(req); + } + } + + private verifyAuthFrame(frame: TTYAAuthFrame): boolean { + // Check timestamp within tolerance + const now = Date.now(); + const diff = Math.abs(now - frame.timestamp); + if (diff > AUTH_TIMESTAMP_TOLERANCE_MS) { + console.warn('[TTYAManager] Auth frame timestamp out of range:', diff, 'ms'); + return false; + } + + // Verify signature: sign(agentPublicKey || uint64BE(timestamp)) + try { + const timestampBuf = Buffer.alloc(8); + timestampBuf.writeBigUInt64BE(BigInt(frame.timestamp), 0); + const message = Buffer.concat([Buffer.from(this.edPublicKey), timestampBuf]); + const signature = Buffer.from(frame.signature, 'hex'); + const bridgePubKey = Buffer.from(frame.bridgePublicKey, 'hex'); + + return verify(signature, message, bridgePubKey); + } catch { + return false; + } + } + + private handleRequest(req: TTYARequest): void { + if (req.action === 'disconnect') { + this.visitors.delete(req.visitorId); + this.emit('visitor:disconnect', req.visitorId); + return; + } + + if (!this.visitors.has(req.visitorId)) { + this.visitors.set(req.visitorId, { + visitorId: req.visitorId, + firstMessage: req.content ?? '', + ipHash: req.metadata.ipHash, + timestamp: req.metadata.timestamp, + status: 'pending', + }); + } + + this.emit('visitor:request', { + visitorId: req.visitorId, + content: req.content, + ipHash: req.metadata.ipHash, + timestamp: req.metadata.timestamp, + }); + } +} diff --git a/packages/web/package.json b/packages/web/package.json index 0852ce2..868deb1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,7 +13,7 @@ "@networkselfmd/core": "workspace:*", "fastify": "^5.2.0", "@fastify/websocket": "^11.0.0", - "@fastify/static": "^8.1.0", + "@fastify/static": "^9.1.1", "@fastify/rate-limit": "^10.2.0", "hyperswarm": "^4.9.0", "b4a": "^1.6.0", diff --git a/packages/web/src/__tests__/static-content.test.ts b/packages/web/src/__tests__/static-content.test.ts index ec5dd23..75f981b 100644 --- a/packages/web/src/__tests__/static-content.test.ts +++ b/packages/web/src/__tests__/static-content.test.ts @@ -22,7 +22,7 @@ describe('getChatHTML', () => { const html = getChatHTML('fp123'); expect(html).toContain('msg-input'); expect(html).toContain('send-btn'); - expect(html).toContain('status-bar'); + expect(html).toContain('status-pill'); expect(html).toContain('messages'); }); }); diff --git a/packages/web/src/bridge.ts b/packages/web/src/bridge.ts index 6253742..c11a09b 100644 --- a/packages/web/src/bridge.ts +++ b/packages/web/src/bridge.ts @@ -9,8 +9,12 @@ import Hyperswarm from 'hyperswarm'; import b4a from 'b4a'; import { createHmac } from 'node:crypto'; +import { sign, generateIdentity } from '@networkselfmd/core'; import type { TTYARequest, TTYAResponse } from './types.js'; +/** Maximum allowed TTYA frame payload size (64 KB). Prevents OOM from malicious peers. */ +const MAX_TTYA_FRAME_SIZE = 65536; + /** * HKDF-SHA256 implementation using Node.js crypto. * topic = hkdf(sha256, ikm, salt, info, length) @@ -53,6 +57,24 @@ function encodeFrame(msg: TTYARequest): Uint8Array { return new Uint8Array(frame); } +const VALID_RESPONSE_ACTIONS = new Set(['approve', 'reject', 'reply']); + +/** + * Runtime validation for TTYAResponse objects received over the wire. + * Rejects messages with missing or wrong-type fields to prevent crashes + * from malicious Hyperswarm peers. + */ +function isValidTTYAResponse(obj: unknown): obj is TTYAResponse { + if (obj === null || typeof obj !== 'object') return false; + const o = obj as Record; + if (o.type !== 0x08) return false; + if (typeof o.visitorId !== 'string') return false; + if (typeof o.action !== 'string' || !VALID_RESPONSE_ACTIONS.has(o.action)) return false; + if (o.content !== undefined && typeof o.content !== 'string') return false; + if (o.sessionToken !== undefined && typeof o.sessionToken !== 'string') return false; + return true; +} + /** * Decode length-prefixed JSON frames from a buffer. * Returns parsed TTYAResponse objects and the number of bytes consumed. @@ -63,11 +85,18 @@ function decodeFrames(data: Buffer): { responses: TTYAResponse[]; consumed: numb while (offset + 4 <= data.length) { const len = data.readUInt32BE(offset); + if (len > MAX_TTYA_FRAME_SIZE) { + throw new Error(`TTYA frame too large: ${len} bytes (max ${MAX_TTYA_FRAME_SIZE})`); + } if (offset + 4 + len > data.length) break; const payload = data.subarray(offset + 4, offset + 4 + len); try { - const msg = JSON.parse(payload.toString('utf-8')) as TTYAResponse; - responses.push(msg); + const parsed: unknown = JSON.parse(payload.toString('utf-8')); + if (isValidTTYAResponse(parsed)) { + responses.push(parsed); + } else { + console.warn('[TTYABridge] Skipping invalid response frame: failed validation'); + } } catch { // skip malformed frames } @@ -77,6 +106,15 @@ function decodeFrames(data: Buffer): { responses: TTYAResponse[]; consumed: numb return { responses, consumed: offset }; } +const MAX_PENDING_REQUESTS = 1000; + +/** Encode a uint64 as 8-byte big-endian buffer */ +function uint64BE(n: number): Buffer { + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(BigInt(n), 0); + return buf; +} + export class TTYABridge { private agentEdPublicKey: Uint8Array; private swarm: Hyperswarm | null = null; @@ -84,9 +122,15 @@ export class TTYABridge { private responseHandler: ((response: TTYAResponse) => void) | null = null; private pendingRequests: TTYARequest[] = []; private receiveBuffer = Buffer.alloc(0); + private bridgeEdPublicKey: Uint8Array; + private bridgeEdPrivateKey: Uint8Array; constructor(agentEdPublicKey: Uint8Array) { this.agentEdPublicKey = agentEdPublicKey; + // Generate ephemeral Ed25519 keypair for bridge authentication + const identity = generateIdentity(); + this.bridgeEdPublicKey = identity.edPublicKey; + this.bridgeEdPrivateKey = identity.edPrivateKey; } /** @@ -109,6 +153,9 @@ export class TTYABridge { this.swarm.on('connection', (conn: any, _info: any) => { this.agentConnection = conn; + // Send auth frame before any TTYARequests + this.sendAuthFrame(); + // Flush any requests that queued before the agent connected for (const req of this.pendingRequests) { this.writeRequest(req); @@ -117,6 +164,14 @@ export class TTYABridge { conn.on('data', (chunk: Buffer) => { this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]); + + if (this.receiveBuffer.length > MAX_TTYA_FRAME_SIZE + 4) { + console.warn(`[TTYABridge] Receive buffer exceeded max size (${this.receiveBuffer.length} bytes), destroying connection`); + this.receiveBuffer = Buffer.alloc(0); + conn.destroy(); + return; + } + this.processReceiveBuffer(); }); @@ -166,6 +221,10 @@ export class TTYABridge { if (this.agentConnection) { this.writeRequest(request); } else { + if (this.pendingRequests.length >= MAX_PENDING_REQUESTS) { + console.warn('[Bridge] Pending request queue full, dropping oldest'); + this.pendingRequests.shift(); + } this.pendingRequests.push(request); } } @@ -182,6 +241,39 @@ export class TTYABridge { return this.agentConnection !== null; } + /** + * Send an authentication frame to the agent. + * Must be the first message on a new connection. + */ + private sendAuthFrame(): void { + if (!this.agentConnection) return; + const timestamp = Date.now(); + const payload = Buffer.concat([ + Buffer.from(this.agentEdPublicKey), + uint64BE(timestamp), + ]); + const signature = sign(payload, this.bridgeEdPrivateKey); + + const authFrame = { + type: 'ttya-auth', + bridgePublicKey: Buffer.from(this.bridgeEdPublicKey).toString('hex'), + timestamp, + signature: Buffer.from(signature).toString('hex'), + }; + + const json = JSON.stringify(authFrame); + const jsonBuf = Buffer.from(json, 'utf-8'); + const frame = Buffer.alloc(4 + jsonBuf.length); + frame.writeUInt32BE(jsonBuf.length, 0); + jsonBuf.copy(frame, 4); + + try { + this.agentConnection.write(frame); + } catch { + // connection may have dropped + } + } + private writeRequest(request: TTYARequest): void { if (!this.agentConnection) return; try { @@ -193,7 +285,18 @@ export class TTYABridge { } private processReceiveBuffer(): void { - const { responses, consumed } = decodeFrames(this.receiveBuffer); + let responses: TTYAResponse[]; + let consumed: number; + try { + ({ responses, consumed } = decodeFrames(this.receiveBuffer)); + } catch (err) { + console.warn('[TTYABridge] Frame decode error, clearing buffer:', err); + this.receiveBuffer = Buffer.alloc(0); + if (this.agentConnection) { + this.agentConnection.destroy(); + } + return; + } if (responses.length === 0) return; this.receiveBuffer = Buffer.from(this.receiveBuffer.subarray(consumed)); diff --git a/packages/web/src/static-content.ts b/packages/web/src/static-content.ts index bf283ee..a2da8df 100644 --- a/packages/web/src/static-content.ts +++ b/packages/web/src/static-content.ts @@ -3,76 +3,155 @@ * Served inline to avoid file-copy issues with TypeScript compilation. */ +function escapeHTML(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + export function getChatHTML(fingerprint: string): string { // JSON.stringify + replace to prevent XSS when embedding in