Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ dist/
*.tsbuildinfo
.env
.DS_Store
.diagrams/
docs/private/
scripts/gen-tldr.mjs
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -16,5 +17,10 @@
},
"engines": {
"node": ">=20.0.0"
},
"pnpm": {
"overrides": {
"serialize-javascript": ">=7.0.5"
}
}
}
30 changes: 29 additions & 1 deletion packages/core/src/__tests__/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/__tests__/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/crypto/index.ts
Original file line number Diff line number Diff line change
@@ -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';
13 changes: 13 additions & 0 deletions packages/core/src/crypto/kdf.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,22 @@
},
"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",
"react-dom": "^18.3.0"
},
"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",
Expand Down
79 changes: 79 additions & 0 deletions packages/dashboard/src/client/__tests__/useRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
66 changes: 64 additions & 2 deletions packages/dashboard/src/server/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
},
};
}

Expand Down Expand Up @@ -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');
});
});
22 changes: 18 additions & 4 deletions packages/dashboard/src/server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<void> {
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' } });
}
}
Expand Down Expand Up @@ -161,12 +171,16 @@ export async function buildApp({ agent }: DashboardAgent) {
}

try {
await agent.joinPublicGroup(id);
const timeout = new Promise<never>((_, 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' };
}
});

Expand Down
32 changes: 32 additions & 0 deletions packages/node/src/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
2 changes: 1 addition & 1 deletion packages/node/src/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
Loading
Loading