@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/API.md +39 -9
- package/CLAUDE.md +47 -0
- package/README.md +144 -187
- package/build.js +12 -0
- package/dist/index.js +799 -266
- package/dist/index.js.map +4 -4
- package/migrations/0001_add_peer_id.sql +21 -0
- package/migrations/0002_remove_topics.sql +22 -0
- package/migrations/0003_remove_origin.sql +29 -0
- package/migrations/0004_add_secret.sql +4 -0
- package/migrations/schema.sql +18 -0
- package/package.json +4 -3
- package/src/app.ts +421 -127
- package/src/bloom.ts +66 -0
- package/src/config.ts +27 -2
- package/src/crypto.ts +149 -0
- package/src/index.ts +28 -12
- package/src/middleware/auth.ts +51 -0
- package/src/storage/d1.ts +394 -0
- package/src/storage/hash-id.ts +37 -0
- package/src/storage/sqlite.ts +323 -178
- package/src/storage/types.ts +128 -54
- package/src/worker.ts +51 -16
- package/wrangler.toml +45 -0
- package/DEPLOYMENT.md +0 -346
- package/src/storage/kv.ts +0 -241
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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('
|
|
50
|
-
|
|
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
|
+
}
|