@xtr-dev/rondevu-server 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bloom.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Bloom filter utility for testing if peer IDs might be in a set
3
+ * Used to filter out known peers from discovery results
4
+ */
5
+
6
+ export class BloomFilter {
7
+ private bits: Uint8Array;
8
+ private size: number;
9
+ private numHashes: number;
10
+
11
+ /**
12
+ * Creates a bloom filter from a base64 encoded bit array
13
+ */
14
+ constructor(base64Data: string, numHashes: number = 3) {
15
+ // Decode base64 to Uint8Array (works in both Node.js and Workers)
16
+ const binaryString = atob(base64Data);
17
+ const bytes = new Uint8Array(binaryString.length);
18
+ for (let i = 0; i < binaryString.length; i++) {
19
+ bytes[i] = binaryString.charCodeAt(i);
20
+ }
21
+ this.bits = bytes;
22
+ this.size = this.bits.length * 8;
23
+ this.numHashes = numHashes;
24
+ }
25
+
26
+ /**
27
+ * Test if a peer ID might be in the filter
28
+ * Returns true if possibly in set, false if definitely not in set
29
+ */
30
+ test(peerId: string): boolean {
31
+ for (let i = 0; i < this.numHashes; i++) {
32
+ const hash = this.hash(peerId, i);
33
+ const index = hash % this.size;
34
+ const byteIndex = Math.floor(index / 8);
35
+ const bitIndex = index % 8;
36
+
37
+ if (!(this.bits[byteIndex] & (1 << bitIndex))) {
38
+ return false;
39
+ }
40
+ }
41
+ return true;
42
+ }
43
+
44
+ /**
45
+ * Simple hash function (FNV-1a variant)
46
+ */
47
+ private hash(str: string, seed: number): number {
48
+ let hash = 2166136261 ^ seed;
49
+ for (let i = 0; i < str.length; i++) {
50
+ hash ^= str.charCodeAt(i);
51
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
52
+ }
53
+ return hash >>> 0;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Helper to parse bloom filter from base64 string
59
+ */
60
+ export function parseBloomFilter(base64: string): BloomFilter | null {
61
+ try {
62
+ return new BloomFilter(base64);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
package/src/config.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { generateSecretKey } from './crypto.ts';
2
+
1
3
  /**
2
4
  * Application configuration
3
5
  * Reads from environment variables with sensible defaults
@@ -6,21 +8,44 @@ export interface Config {
6
8
  port: number;
7
9
  storageType: 'sqlite' | 'memory';
8
10
  storagePath: string;
9
- sessionTimeout: number;
10
11
  corsOrigins: string[];
12
+ version: string;
13
+ authSecret: string;
14
+ offerDefaultTtl: number;
15
+ offerMaxTtl: number;
16
+ offerMinTtl: number;
17
+ cleanupInterval: number;
18
+ maxOffersPerRequest: number;
19
+ maxTopicsPerOffer: number;
11
20
  }
12
21
 
13
22
  /**
14
23
  * Loads configuration from environment variables
15
24
  */
16
25
  export function loadConfig(): Config {
26
+ // Generate or load auth secret
27
+ let authSecret = process.env.AUTH_SECRET;
28
+ if (!authSecret) {
29
+ authSecret = generateSecretKey();
30
+ console.warn('WARNING: No AUTH_SECRET provided. Generated temporary secret:', authSecret);
31
+ console.warn('All peer credentials will be invalidated on server restart.');
32
+ console.warn('Set AUTH_SECRET environment variable to persist credentials across restarts.');
33
+ }
34
+
17
35
  return {
18
36
  port: parseInt(process.env.PORT || '3000', 10),
19
37
  storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory',
20
38
  storagePath: process.env.STORAGE_PATH || ':memory:',
21
- sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || '300000', 10),
22
39
  corsOrigins: process.env.CORS_ORIGINS
23
40
  ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
24
41
  : ['*'],
42
+ version: process.env.VERSION || 'unknown',
43
+ authSecret,
44
+ offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || '60000', 10),
45
+ offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
46
+ offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),
47
+ cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10),
48
+ maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10),
49
+ maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || '50', 10),
25
50
  };
26
51
  }
package/src/crypto.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Crypto utilities for stateless peer authentication
3
+ * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
4
+ */
5
+
6
+ const ALGORITHM = 'AES-GCM';
7
+ const IV_LENGTH = 12; // 96 bits for GCM
8
+ const KEY_LENGTH = 32; // 256 bits
9
+
10
+ /**
11
+ * Generates a random peer ID (16 bytes = 32 hex chars)
12
+ */
13
+ export function generatePeerId(): string {
14
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
15
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
16
+ }
17
+
18
+ /**
19
+ * Generates a random secret key for encryption (32 bytes = 64 hex chars)
20
+ */
21
+ export function generateSecretKey(): string {
22
+ const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
23
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
24
+ }
25
+
26
+ /**
27
+ * Convert hex string to Uint8Array
28
+ */
29
+ function hexToBytes(hex: string): Uint8Array {
30
+ const bytes = new Uint8Array(hex.length / 2);
31
+ for (let i = 0; i < hex.length; i += 2) {
32
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
33
+ }
34
+ return bytes;
35
+ }
36
+
37
+ /**
38
+ * Convert Uint8Array to base64 string
39
+ */
40
+ function bytesToBase64(bytes: Uint8Array): string {
41
+ const binString = Array.from(bytes, (byte) =>
42
+ String.fromCodePoint(byte)
43
+ ).join('');
44
+ return btoa(binString);
45
+ }
46
+
47
+ /**
48
+ * Convert base64 string to Uint8Array
49
+ */
50
+ function base64ToBytes(base64: string): Uint8Array {
51
+ const binString = atob(base64);
52
+ return Uint8Array.from(binString, (char) => char.codePointAt(0)!);
53
+ }
54
+
55
+ /**
56
+ * Encrypts a peer ID using the server secret key
57
+ * Returns base64-encoded encrypted data (IV + ciphertext)
58
+ */
59
+ export async function encryptPeerId(peerId: string, secretKeyHex: string): Promise<string> {
60
+ const keyBytes = hexToBytes(secretKeyHex);
61
+
62
+ if (keyBytes.length !== KEY_LENGTH) {
63
+ throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
64
+ }
65
+
66
+ // Import key
67
+ const key = await crypto.subtle.importKey(
68
+ 'raw',
69
+ keyBytes,
70
+ { name: ALGORITHM, length: 256 },
71
+ false,
72
+ ['encrypt']
73
+ );
74
+
75
+ // Generate random IV
76
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
77
+
78
+ // Encrypt peer ID
79
+ const encoder = new TextEncoder();
80
+ const data = encoder.encode(peerId);
81
+
82
+ const encrypted = await crypto.subtle.encrypt(
83
+ { name: ALGORITHM, iv },
84
+ key,
85
+ data
86
+ );
87
+
88
+ // Combine IV + ciphertext and encode as base64
89
+ const combined = new Uint8Array(iv.length + encrypted.byteLength);
90
+ combined.set(iv, 0);
91
+ combined.set(new Uint8Array(encrypted), iv.length);
92
+
93
+ return bytesToBase64(combined);
94
+ }
95
+
96
+ /**
97
+ * Decrypts an encrypted peer ID secret
98
+ * Returns the plaintext peer ID or throws if decryption fails
99
+ */
100
+ export async function decryptPeerId(encryptedSecret: string, secretKeyHex: string): Promise<string> {
101
+ try {
102
+ const keyBytes = hexToBytes(secretKeyHex);
103
+
104
+ if (keyBytes.length !== KEY_LENGTH) {
105
+ throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
106
+ }
107
+
108
+ // Decode base64
109
+ const combined = base64ToBytes(encryptedSecret);
110
+
111
+ // Extract IV and ciphertext
112
+ const iv = combined.slice(0, IV_LENGTH);
113
+ const ciphertext = combined.slice(IV_LENGTH);
114
+
115
+ // Import key
116
+ const key = await crypto.subtle.importKey(
117
+ 'raw',
118
+ keyBytes,
119
+ { name: ALGORITHM, length: 256 },
120
+ false,
121
+ ['decrypt']
122
+ );
123
+
124
+ // Decrypt
125
+ const decrypted = await crypto.subtle.decrypt(
126
+ { name: ALGORITHM, iv },
127
+ key,
128
+ ciphertext
129
+ );
130
+
131
+ const decoder = new TextDecoder();
132
+ return decoder.decode(decrypted);
133
+ } catch (err) {
134
+ throw new Error('Failed to decrypt peer ID: invalid secret or secret key');
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Validates that a peer ID and secret match
140
+ * Returns true if valid, false otherwise
141
+ */
142
+ export async function validateCredentials(peerId: string, encryptedSecret: string, secretKey: string): Promise<boolean> {
143
+ try {
144
+ const decryptedPeerId = await decryptPeerId(encryptedSecret, secretKey);
145
+ return decryptedPeerId === peerId;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
package/src/index.ts CHANGED
@@ -15,8 +15,14 @@ async function main() {
15
15
  port: config.port,
16
16
  storageType: config.storageType,
17
17
  storagePath: config.storagePath,
18
- sessionTimeout: `${config.sessionTimeout}ms`,
18
+ offerDefaultTtl: `${config.offerDefaultTtl}ms`,
19
+ offerMaxTtl: `${config.offerMaxTtl}ms`,
20
+ offerMinTtl: `${config.offerMinTtl}ms`,
21
+ cleanupInterval: `${config.cleanupInterval}ms`,
22
+ maxOffersPerRequest: config.maxOffersPerRequest,
23
+ maxTopicsPerOffer: config.maxTopicsPerOffer,
19
24
  corsOrigins: config.corsOrigins,
25
+ version: config.version,
20
26
  });
21
27
 
22
28
  let storage: Storage;
@@ -28,10 +34,20 @@ async function main() {
28
34
  throw new Error('Unsupported storage type');
29
35
  }
30
36
 
31
- const app = createApp(storage, {
32
- sessionTimeout: config.sessionTimeout,
33
- corsOrigins: config.corsOrigins,
34
- });
37
+ // Start periodic cleanup of expired offers
38
+ const cleanupInterval = setInterval(async () => {
39
+ try {
40
+ const now = Date.now();
41
+ const deleted = await storage.deleteExpiredOffers(now);
42
+ if (deleted > 0) {
43
+ console.log(`Cleanup: Deleted ${deleted} expired offer(s)`);
44
+ }
45
+ } catch (err) {
46
+ console.error('Cleanup error:', err);
47
+ }
48
+ }, config.cleanupInterval);
49
+
50
+ const app = createApp(storage, config);
35
51
 
36
52
  const server = serve({
37
53
  fetch: app.fetch,
@@ -39,18 +55,18 @@ async function main() {
39
55
  });
40
56
 
41
57
  console.log(`Server running on http://localhost:${config.port}`);
58
+ console.log('Ready to accept connections');
42
59
 
43
- process.on('SIGINT', async () => {
60
+ // Graceful shutdown handler
61
+ const shutdown = async () => {
44
62
  console.log('\nShutting down gracefully...');
63
+ clearInterval(cleanupInterval);
45
64
  await storage.close();
46
65
  process.exit(0);
47
- });
66
+ };
48
67
 
49
- process.on('SIGTERM', async () => {
50
- console.log('\nShutting down gracefully...');
51
- await storage.close();
52
- process.exit(0);
53
- });
68
+ process.on('SIGINT', shutdown);
69
+ process.on('SIGTERM', shutdown);
54
70
  }
55
71
 
56
72
  main().catch((err) => {
@@ -0,0 +1,51 @@
1
+ import { Context, Next } from 'hono';
2
+ import { validateCredentials } from '../crypto.ts';
3
+
4
+ /**
5
+ * Authentication middleware for Rondevu
6
+ * Validates Bearer token in format: {peerId}:{encryptedSecret}
7
+ */
8
+ export function createAuthMiddleware(authSecret: string) {
9
+ return async (c: Context, next: Next) => {
10
+ const authHeader = c.req.header('Authorization');
11
+
12
+ if (!authHeader) {
13
+ return c.json({ error: 'Missing Authorization header' }, 401);
14
+ }
15
+
16
+ // Expect format: Bearer {peerId}:{secret}
17
+ const parts = authHeader.split(' ');
18
+ if (parts.length !== 2 || parts[0] !== 'Bearer') {
19
+ return c.json({ error: 'Invalid Authorization header format. Expected: Bearer {peerId}:{secret}' }, 401);
20
+ }
21
+
22
+ const credentials = parts[1].split(':');
23
+ if (credentials.length !== 2) {
24
+ return c.json({ error: 'Invalid credentials format. Expected: {peerId}:{secret}' }, 401);
25
+ }
26
+
27
+ const [peerId, encryptedSecret] = credentials;
28
+
29
+ // Validate credentials (async operation)
30
+ const isValid = await validateCredentials(peerId, encryptedSecret, authSecret);
31
+ if (!isValid) {
32
+ return c.json({ error: 'Invalid credentials' }, 401);
33
+ }
34
+
35
+ // Attach peer ID to context for use in handlers
36
+ c.set('peerId', peerId);
37
+
38
+ await next();
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Helper to get authenticated peer ID from context
44
+ */
45
+ export function getAuthenticatedPeerId(c: Context): string {
46
+ const peerId = c.get('peerId');
47
+ if (!peerId) {
48
+ throw new Error('No authenticated peer ID in context');
49
+ }
50
+ return peerId;
51
+ }