@xtr-dev/rondevu-server 0.5.1 → 0.5.7
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/.github/workflows/claude-code-review.yml +57 -0
- package/.github/workflows/claude.yml +50 -0
- package/.idea/modules.xml +8 -0
- package/.idea/rondevu-server.iml +8 -0
- package/.idea/workspace.xml +17 -0
- package/README.md +80 -199
- package/build.js +4 -1
- package/dist/index.js +2891 -1446
- package/dist/index.js.map +4 -4
- package/migrations/fresh_schema.sql +36 -41
- package/package.json +10 -4
- package/src/app.ts +38 -18
- package/src/config.ts +183 -9
- package/src/crypto.ts +361 -263
- package/src/index.ts +20 -25
- package/src/rpc.ts +714 -403
- package/src/storage/d1.ts +338 -263
- package/src/storage/factory.ts +69 -0
- package/src/storage/hash-id.ts +13 -9
- package/src/storage/memory.ts +579 -0
- package/src/storage/mysql.ts +616 -0
- package/src/storage/postgres.ts +623 -0
- package/src/storage/schemas/mysql.sql +59 -0
- package/src/storage/schemas/postgres.sql +64 -0
- package/src/storage/sqlite.ts +325 -269
- package/src/storage/types.ts +137 -109
- package/src/worker.ts +15 -34
- package/tests/integration/api.test.ts +395 -0
- package/tests/integration/setup.ts +170 -0
- package/wrangler.toml +25 -26
- package/ADVANCED.md +0 -502
package/src/crypto.ts
CHANGED
|
@@ -1,379 +1,477 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Crypto utilities for
|
|
3
|
-
* Uses @noble/ed25519 for Ed25519 signature verification
|
|
2
|
+
* Crypto utilities for credential generation and validation
|
|
4
3
|
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
|
-
import * as ed25519 from '@noble/ed25519';
|
|
8
6
|
import { Buffer } from 'node:buffer';
|
|
9
7
|
|
|
10
|
-
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
|
|
11
|
-
// Uses Web Crypto API (compatible with both Node.js and Cloudflare Workers)
|
|
12
|
-
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
|
|
13
|
-
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
|
|
14
|
-
};
|
|
15
|
-
|
|
16
8
|
// Username validation
|
|
17
|
-
|
|
18
|
-
const
|
|
9
|
+
// Rules: 4-32 chars, lowercase alphanumeric + dashes + periods, must start/end with alphanumeric
|
|
10
|
+
const USERNAME_REGEX = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/;
|
|
11
|
+
const USERNAME_MIN_LENGTH = 4;
|
|
19
12
|
const USERNAME_MAX_LENGTH = 32;
|
|
20
13
|
|
|
21
|
-
// Timestamp validation (5 minutes tolerance)
|
|
22
|
-
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
|
|
23
|
-
|
|
24
14
|
/**
|
|
25
|
-
* Generates
|
|
26
|
-
* Format:
|
|
27
|
-
*
|
|
15
|
+
* Generates a random credential name
|
|
16
|
+
* Format: {adjective}-{noun}-{random}
|
|
17
|
+
* Example: "brave-tiger-7a3f2b1c9d8e", "quick-river-9b2e4c1a5f3d"
|
|
28
18
|
*/
|
|
29
|
-
export function
|
|
30
|
-
const
|
|
31
|
-
|
|
19
|
+
export function generateCredentialName(): string {
|
|
20
|
+
const adjectives = [
|
|
21
|
+
'brave', 'calm', 'eager', 'fancy', 'gentle', 'happy', 'jolly', 'kind',
|
|
22
|
+
'lively', 'merry', 'nice', 'proud', 'quiet', 'swift', 'witty', 'young',
|
|
23
|
+
'bright', 'clever', 'daring', 'fair', 'grand', 'humble', 'noble', 'quick'
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const nouns = [
|
|
27
|
+
'tiger', 'eagle', 'river', 'mountain', 'ocean', 'forest', 'desert', 'valley',
|
|
28
|
+
'thunder', 'wind', 'fire', 'stone', 'cloud', 'star', 'moon', 'sun',
|
|
29
|
+
'wolf', 'bear', 'hawk', 'lion', 'fox', 'deer', 'owl', 'swan'
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
33
|
+
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
34
|
+
|
|
35
|
+
// Generate 16-character hex suffix for uniqueness (8 bytes = 2^64 combinations)
|
|
36
|
+
// With 576 adjective-noun pairs, total space: 576 × 2^64 ≈ 1.06 × 10^22 names
|
|
37
|
+
// Birthday paradox collision at ~4.3 billion credentials (extremely safe for large deployments)
|
|
38
|
+
// Increased from 6 bytes to 8 bytes for maximum collision resistance
|
|
39
|
+
const random = crypto.getRandomValues(new Uint8Array(8));
|
|
32
40
|
const hex = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
33
|
-
return `anon-${timestamp}-${hex}`;
|
|
34
|
-
}
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
* Convert Uint8Array to base64 string
|
|
38
|
-
* Uses Buffer for compatibility with Node.js-based clients
|
|
39
|
-
*/
|
|
40
|
-
function bytesToBase64(bytes: Uint8Array): string {
|
|
41
|
-
return Buffer.from(bytes).toString('base64');
|
|
42
|
+
return `${adjective}-${noun}-${hex}`;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
46
|
+
* Generates a random secret (API key style)
|
|
47
|
+
* Format: 64-character hex string (256 bits of entropy)
|
|
48
|
+
* 256 bits provides optimal security for HMAC-SHA256 and future-proofs against brute force
|
|
47
49
|
*/
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
export function generateSecret(): string {
|
|
51
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32)); // 32 bytes = 256 bits
|
|
52
|
+
const secret = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
* Validates that the message contains the expected username and has a valid timestamp
|
|
56
|
-
*/
|
|
57
|
-
export function validateAuthMessage(
|
|
58
|
-
expectedUsername: string,
|
|
59
|
-
message: string
|
|
60
|
-
): { valid: boolean; error?: string } {
|
|
61
|
-
const parts = message.split(':');
|
|
62
|
-
|
|
63
|
-
if (parts.length < 3) {
|
|
64
|
-
return { valid: false, error: 'Invalid message format: must have at least action:username:timestamp' };
|
|
54
|
+
// Validation: Ensure output is exactly 64 characters and valid hex
|
|
55
|
+
if (secret.length !== 64) {
|
|
56
|
+
throw new Error('Secret generation failed: invalid length');
|
|
65
57
|
}
|
|
66
58
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return { valid: false, error: 'Username in message does not match authenticated username' };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Validate timestamp
|
|
77
|
-
if (isNaN(timestamp)) {
|
|
78
|
-
return { valid: false, error: 'Invalid timestamp in message' };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const timestampCheck = validateTimestamp(timestamp);
|
|
82
|
-
if (!timestampCheck.valid) {
|
|
83
|
-
return timestampCheck;
|
|
59
|
+
// Validate all characters are valid hex digits (0-9, a-f)
|
|
60
|
+
for (let i = 0; i < secret.length; i++) {
|
|
61
|
+
const c = secret[i];
|
|
62
|
+
if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
|
|
63
|
+
throw new Error(`Secret generation failed: invalid hex character at position ${i}: '${c}'`);
|
|
64
|
+
}
|
|
84
65
|
}
|
|
85
66
|
|
|
86
|
-
return
|
|
67
|
+
return secret;
|
|
87
68
|
}
|
|
88
69
|
|
|
89
|
-
// =====
|
|
70
|
+
// ===== Secret Encryption/Decryption (Database Storage) =====
|
|
90
71
|
|
|
91
72
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
73
|
+
* Convert hex string to byte array with validation
|
|
74
|
+
* @param hex Hex string (must be even length)
|
|
75
|
+
* @returns Uint8Array of bytes
|
|
94
76
|
*/
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
77
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
78
|
+
if (hex.length % 2 !== 0) {
|
|
79
|
+
throw new Error('Hex string must have even length');
|
|
98
80
|
}
|
|
99
81
|
|
|
100
|
-
|
|
101
|
-
|
|
82
|
+
// Pre-validate that all characters are valid hex digits (0-9, a-f, A-F)
|
|
83
|
+
// This prevents parseInt from silently truncating invalid input like "0z" -> 0
|
|
84
|
+
for (let i = 0; i < hex.length; i++) {
|
|
85
|
+
const c = hex[i].toLowerCase();
|
|
86
|
+
if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
|
|
87
|
+
throw new Error(`Invalid hex character at position ${i}: '${hex[i]}'`);
|
|
88
|
+
}
|
|
102
89
|
}
|
|
103
90
|
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
const match = hex.match(/.{1,2}/g);
|
|
92
|
+
if (!match) {
|
|
93
|
+
throw new Error('Invalid hex string format');
|
|
106
94
|
}
|
|
107
95
|
|
|
108
|
-
|
|
109
|
-
|
|
96
|
+
return new Uint8Array(match.map(byte => {
|
|
97
|
+
const parsed = parseInt(byte, 16);
|
|
98
|
+
if (isNaN(parsed)) {
|
|
99
|
+
throw new Error(`Invalid hex byte: ${byte}`);
|
|
100
|
+
}
|
|
101
|
+
return parsed;
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Encrypt a secret using AES-256-GCM with master key
|
|
107
|
+
* Format: iv:ciphertext (all hex-encoded, auth tag included in ciphertext)
|
|
108
|
+
*
|
|
109
|
+
* @param secret The plaintext secret to encrypt
|
|
110
|
+
* @param masterKeyHex The master encryption key (64-char hex = 32 bytes)
|
|
111
|
+
* @returns Encrypted secret in format "iv:ciphertext"
|
|
112
|
+
*/
|
|
113
|
+
export async function encryptSecret(secret: string, masterKeyHex: string): Promise<string> {
|
|
114
|
+
// Validate master key
|
|
115
|
+
if (!masterKeyHex || masterKeyHex.length !== 64) {
|
|
116
|
+
throw new Error('Master key must be 64-character hex string (32 bytes)');
|
|
110
117
|
}
|
|
111
118
|
|
|
112
|
-
|
|
119
|
+
// Convert master key from hex to bytes (with validation)
|
|
120
|
+
const keyBytes = hexToBytes(masterKeyHex);
|
|
121
|
+
|
|
122
|
+
// Import master key
|
|
123
|
+
const key = await crypto.subtle.importKey(
|
|
124
|
+
'raw',
|
|
125
|
+
keyBytes,
|
|
126
|
+
{ name: 'AES-GCM', length: 256 },
|
|
127
|
+
false,
|
|
128
|
+
['encrypt']
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Generate random IV (12 bytes for AES-GCM)
|
|
132
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
133
|
+
|
|
134
|
+
// Encrypt secret
|
|
135
|
+
const encoder = new TextEncoder();
|
|
136
|
+
const secretBytes = encoder.encode(secret);
|
|
137
|
+
|
|
138
|
+
// AES-GCM returns ciphertext with auth tag already appended (no manual splitting needed)
|
|
139
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
140
|
+
{ name: 'AES-GCM', iv, tagLength: 128 },
|
|
141
|
+
key,
|
|
142
|
+
secretBytes
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Convert to hex: iv:ciphertext (ciphertext includes 16-byte auth tag at end)
|
|
146
|
+
const ivHex = Array.from(iv).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
147
|
+
const ciphertextHex = Array.from(new Uint8Array(ciphertext))
|
|
148
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
149
|
+
.join('');
|
|
150
|
+
|
|
151
|
+
return `${ivHex}:${ciphertextHex}`;
|
|
113
152
|
}
|
|
114
153
|
|
|
115
154
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
155
|
+
* Decrypt a secret using AES-256-GCM with master key
|
|
156
|
+
*
|
|
157
|
+
* @param encryptedSecret Encrypted secret in format "iv:ciphertext" (ciphertext includes auth tag)
|
|
158
|
+
* @param masterKeyHex The master encryption key (64-char hex = 32 bytes)
|
|
159
|
+
* @returns Decrypted plaintext secret
|
|
120
160
|
*/
|
|
121
|
-
export function
|
|
122
|
-
|
|
123
|
-
|
|
161
|
+
export async function decryptSecret(encryptedSecret: string, masterKeyHex: string): Promise<string> {
|
|
162
|
+
// Validate master key
|
|
163
|
+
if (!masterKeyHex || masterKeyHex.length !== 64) {
|
|
164
|
+
throw new Error('Master key must be 64-character hex string (32 bytes)');
|
|
124
165
|
}
|
|
125
166
|
|
|
126
|
-
// Parse
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
129
|
-
|
|
167
|
+
// Parse encrypted format: iv:ciphertext
|
|
168
|
+
const parts = encryptedSecret.split(':');
|
|
169
|
+
if (parts.length !== 2) {
|
|
170
|
+
throw new Error('Invalid encrypted secret format (expected iv:ciphertext)');
|
|
130
171
|
}
|
|
131
172
|
|
|
132
|
-
const
|
|
173
|
+
const [ivHex, ciphertextHex] = parts;
|
|
133
174
|
|
|
134
|
-
// Validate
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' };
|
|
175
|
+
// Validate IV length (must be 12 bytes = 24 hex characters for AES-GCM)
|
|
176
|
+
if (ivHex.length !== 24) {
|
|
177
|
+
throw new Error('Invalid IV length (expected 12 bytes = 24 hex characters)');
|
|
138
178
|
}
|
|
139
179
|
|
|
140
|
-
|
|
141
|
-
|
|
180
|
+
// Validate ciphertext length (must include at least 16-byte auth tag)
|
|
181
|
+
// Minimum: 16 bytes for auth tag = 32 hex characters
|
|
182
|
+
if (ciphertextHex.length < 32) {
|
|
183
|
+
throw new Error('Invalid ciphertext length (must include 16-byte auth tag)');
|
|
142
184
|
}
|
|
143
185
|
|
|
144
|
-
//
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
186
|
+
// Convert from hex to bytes (with validation)
|
|
187
|
+
const iv = hexToBytes(ivHex);
|
|
188
|
+
const ciphertext = hexToBytes(ciphertextHex);
|
|
189
|
+
|
|
190
|
+
// Convert master key from hex to bytes (with validation)
|
|
191
|
+
const keyBytes = hexToBytes(masterKeyHex);
|
|
192
|
+
|
|
193
|
+
// Import master key
|
|
194
|
+
const key = await crypto.subtle.importKey(
|
|
195
|
+
'raw',
|
|
196
|
+
keyBytes,
|
|
197
|
+
{ name: 'AES-GCM', length: 256 },
|
|
198
|
+
false,
|
|
199
|
+
['decrypt']
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Decrypt (ciphertext already includes 16-byte auth tag at end)
|
|
203
|
+
const decryptedBytes = await crypto.subtle.decrypt(
|
|
204
|
+
{ name: 'AES-GCM', iv, tagLength: 128 },
|
|
205
|
+
key,
|
|
206
|
+
ciphertext
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Convert to string
|
|
210
|
+
const decoder = new TextDecoder();
|
|
211
|
+
return decoder.decode(decryptedBytes);
|
|
212
|
+
}
|
|
149
213
|
|
|
150
|
-
|
|
151
|
-
if (username) {
|
|
152
|
-
const usernameCheck = validateUsername(username);
|
|
153
|
-
if (!usernameCheck.valid) {
|
|
154
|
-
return usernameCheck;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
214
|
+
// ===== HMAC Signature Generation and Verification =====
|
|
157
215
|
|
|
158
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Generate HMAC-SHA256 signature for request authentication
|
|
218
|
+
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
|
|
219
|
+
*
|
|
220
|
+
* @param secret The credential secret (hex string)
|
|
221
|
+
* @param message The message to sign (typically: timestamp + method + params)
|
|
222
|
+
* @returns Promise<string> Base64-encoded signature
|
|
223
|
+
*/
|
|
224
|
+
export async function generateSignature(secret: string, message: string): Promise<string> {
|
|
225
|
+
// Convert secret from hex to bytes (with validation)
|
|
226
|
+
const secretBytes = hexToBytes(secret);
|
|
227
|
+
|
|
228
|
+
// Import secret as HMAC key
|
|
229
|
+
const key = await crypto.subtle.importKey(
|
|
230
|
+
'raw',
|
|
231
|
+
secretBytes,
|
|
232
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
233
|
+
false,
|
|
234
|
+
['sign']
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Convert message to bytes
|
|
238
|
+
const encoder = new TextEncoder();
|
|
239
|
+
const messageBytes = encoder.encode(message);
|
|
240
|
+
|
|
241
|
+
// Generate HMAC signature
|
|
242
|
+
const signatureBytes = await crypto.subtle.sign('HMAC', key, messageBytes);
|
|
243
|
+
|
|
244
|
+
// Convert to base64
|
|
245
|
+
return Buffer.from(signatureBytes).toString('base64');
|
|
159
246
|
}
|
|
160
247
|
|
|
161
248
|
/**
|
|
162
|
-
*
|
|
249
|
+
* Verify HMAC-SHA256 signature for request authentication
|
|
250
|
+
* Uses crypto.subtle.verify() for constant-time comparison
|
|
251
|
+
*
|
|
252
|
+
* @param secret The credential secret (hex string)
|
|
253
|
+
* @param message The message that was signed
|
|
254
|
+
* @param signature The signature to verify (base64)
|
|
255
|
+
* @returns Promise<boolean> True if signature is valid
|
|
163
256
|
*/
|
|
164
|
-
export function
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
257
|
+
export async function verifySignature(secret: string, message: string, signature: string): Promise<boolean> {
|
|
258
|
+
try {
|
|
259
|
+
// Convert secret from hex to bytes (with validation)
|
|
260
|
+
const secretBytes = hexToBytes(secret);
|
|
261
|
+
|
|
262
|
+
// Import secret as HMAC key for verification
|
|
263
|
+
const key = await crypto.subtle.importKey(
|
|
264
|
+
'raw',
|
|
265
|
+
secretBytes,
|
|
266
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
267
|
+
false,
|
|
268
|
+
['verify']
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// Convert message to bytes
|
|
272
|
+
const encoder = new TextEncoder();
|
|
273
|
+
const messageBytes = encoder.encode(message);
|
|
274
|
+
|
|
275
|
+
// Convert signature from base64 to bytes
|
|
276
|
+
const signatureBytes = Buffer.from(signature, 'base64');
|
|
277
|
+
|
|
278
|
+
// Use Web Crypto API's verify() for constant-time comparison
|
|
279
|
+
// This is cryptographically secure and resistant to timing attacks
|
|
280
|
+
return await crypto.subtle.verify('HMAC', key, signatureBytes, messageBytes);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
// Log error for debugging (helps identify implementation bugs)
|
|
283
|
+
console.error('Signature verification error:', error);
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
174
286
|
}
|
|
175
287
|
|
|
176
288
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
289
|
+
* Canonical JSON serialization with sorted keys
|
|
290
|
+
* Ensures deterministic output regardless of property insertion order
|
|
291
|
+
* Must match client's canonicalJSON implementation exactly
|
|
179
292
|
*/
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
const avail = parseVersion(available);
|
|
293
|
+
function canonicalJSON(obj: any, depth: number = 0): string {
|
|
294
|
+
const MAX_DEPTH = 100;
|
|
183
295
|
|
|
184
|
-
if (
|
|
296
|
+
if (depth > MAX_DEPTH) {
|
|
297
|
+
throw new Error('Object nesting too deep for canonicalization');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (obj === null) return 'null';
|
|
301
|
+
if (obj === undefined) return JSON.stringify(undefined);
|
|
185
302
|
|
|
186
|
-
|
|
187
|
-
if (req.major !== avail.major) return false;
|
|
303
|
+
const type = typeof obj;
|
|
188
304
|
|
|
189
|
-
|
|
190
|
-
if (
|
|
305
|
+
if (type === 'function') throw new Error('Functions are not supported in RPC parameters');
|
|
306
|
+
if (type === 'symbol' || type === 'bigint') throw new Error(`${type} is not supported in RPC parameters`);
|
|
307
|
+
if (type === 'number' && !Number.isFinite(obj)) throw new Error('NaN and Infinity are not supported in RPC parameters');
|
|
191
308
|
|
|
192
|
-
|
|
193
|
-
if (avail.minor < req.minor) return false;
|
|
194
|
-
if (avail.minor === req.minor && avail.patch < req.patch) return false;
|
|
309
|
+
if (type !== 'object') return JSON.stringify(obj);
|
|
195
310
|
|
|
196
|
-
|
|
197
|
-
|
|
311
|
+
if (Array.isArray(obj)) {
|
|
312
|
+
return '[' + obj.map(item => canonicalJSON(item, depth + 1)).join(',') + ']';
|
|
313
|
+
}
|
|
198
314
|
|
|
199
|
-
|
|
315
|
+
const sortedKeys = Object.keys(obj).sort();
|
|
316
|
+
const pairs = sortedKeys.map(key => JSON.stringify(key) + ':' + canonicalJSON(obj[key], depth + 1));
|
|
317
|
+
return '{' + pairs.join(',') + '}';
|
|
200
318
|
}
|
|
201
319
|
|
|
202
320
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
321
|
+
* Build the message string for signing
|
|
322
|
+
* Format: timestamp:nonce:method:canonicalJSON(params || {})
|
|
323
|
+
* Uses colons as delimiters to prevent collision attacks
|
|
324
|
+
* Includes nonce to prevent signature reuse within timestamp window
|
|
325
|
+
* Uses canonical JSON (sorted keys) for deterministic serialization
|
|
326
|
+
*
|
|
327
|
+
* @param timestamp Unix timestamp in milliseconds
|
|
328
|
+
* @param nonce Cryptographic nonce (UUID v4) to prevent replay attacks
|
|
329
|
+
* @param method RPC method name
|
|
330
|
+
* @param params RPC method parameters (optional)
|
|
331
|
+
* @returns String to be signed
|
|
207
332
|
*/
|
|
208
|
-
export function
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
333
|
+
export function buildSignatureMessage(timestamp: number, nonce: string, method: string, params?: any): string {
|
|
334
|
+
// Validate nonce is UUID v4 format to prevent colon injection attacks
|
|
335
|
+
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (8-4-4-4-12 hex digits with dashes)
|
|
336
|
+
// Use simple format checks instead of regex to avoid any timing or ReDoS concerns
|
|
337
|
+
|
|
338
|
+
// Check total length (36 characters for UUID v4)
|
|
339
|
+
if (nonce.length !== 36) {
|
|
340
|
+
throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
|
|
341
|
+
}
|
|
215
342
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
username = fqn.substring(atIndex + 1);
|
|
220
|
-
} else {
|
|
221
|
-
// Format: service:version (no username)
|
|
222
|
-
serviceVersion = fqn;
|
|
343
|
+
// Check dash positions (indices 8, 13, 18, 23)
|
|
344
|
+
if (nonce[8] !== '-' || nonce[13] !== '-' || nonce[18] !== '-' || nonce[23] !== '-') {
|
|
345
|
+
throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
|
|
223
346
|
}
|
|
224
347
|
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
348
|
+
// Check version (character at index 14 must be '4')
|
|
349
|
+
if (nonce[14] !== '4') {
|
|
350
|
+
throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
|
|
351
|
+
}
|
|
228
352
|
|
|
229
|
-
|
|
230
|
-
const
|
|
353
|
+
// Check variant (character at index 19 must be 8, 9, a, or b)
|
|
354
|
+
const variant = nonce[19].toLowerCase();
|
|
355
|
+
if (variant !== '8' && variant !== '9' && variant !== 'a' && variant !== 'b') {
|
|
356
|
+
throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
|
|
357
|
+
}
|
|
231
358
|
|
|
232
|
-
|
|
359
|
+
// Validate all other characters are hex digits (0-9, a-f)
|
|
360
|
+
const hexChars = nonce.replace(/-/g, ''); // Remove dashes
|
|
361
|
+
for (let i = 0; i < hexChars.length; i++) {
|
|
362
|
+
const c = hexChars[i].toLowerCase();
|
|
363
|
+
if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
|
|
364
|
+
throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
233
367
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
368
|
+
// Use canonical JSON (sorted keys) to match client's signature
|
|
369
|
+
const paramsStr = canonicalJSON(params || {});
|
|
370
|
+
// Use delimiters to prevent collision: timestamp=12,method="34" vs timestamp=1,method="234"
|
|
371
|
+
// Include nonce to make each request unique (prevents signature reuse in same millisecond)
|
|
372
|
+
return `${timestamp}:${nonce}:${method}:${paramsStr}`;
|
|
239
373
|
}
|
|
240
374
|
|
|
375
|
+
// ===== Username Validation =====
|
|
376
|
+
|
|
241
377
|
/**
|
|
242
|
-
* Validates
|
|
378
|
+
* Validates username format
|
|
379
|
+
* Rules: 4-32 chars, lowercase alphanumeric + dashes + periods, must start/end with alphanumeric
|
|
243
380
|
*/
|
|
244
|
-
export function
|
|
245
|
-
if (typeof
|
|
246
|
-
return { valid: false, error: '
|
|
381
|
+
export function validateUsername(username: string): { valid: boolean; error?: string } {
|
|
382
|
+
if (typeof username !== 'string') {
|
|
383
|
+
return { valid: false, error: 'Username must be a string' };
|
|
247
384
|
}
|
|
248
385
|
|
|
249
|
-
|
|
250
|
-
|
|
386
|
+
if (username.length < USERNAME_MIN_LENGTH) {
|
|
387
|
+
return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (username.length > USERNAME_MAX_LENGTH) {
|
|
391
|
+
return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` };
|
|
392
|
+
}
|
|
251
393
|
|
|
252
|
-
if (
|
|
253
|
-
return { valid: false, error:
|
|
394
|
+
if (!USERNAME_REGEX.test(username)) {
|
|
395
|
+
return { valid: false, error: 'Username must be lowercase alphanumeric with optional dashes/periods, and start/end with alphanumeric' };
|
|
254
396
|
}
|
|
255
397
|
|
|
256
398
|
return { valid: true };
|
|
257
399
|
}
|
|
258
400
|
|
|
259
|
-
|
|
260
|
-
* Verifies Ed25519 signature
|
|
261
|
-
* @param publicKey Base64-encoded Ed25519 public key (32 bytes)
|
|
262
|
-
* @param signature Base64-encoded Ed25519 signature (64 bytes)
|
|
263
|
-
* @param message Message that was signed (UTF-8 string)
|
|
264
|
-
* @returns true if signature is valid, false otherwise
|
|
265
|
-
*/
|
|
266
|
-
export async function verifyEd25519Signature(
|
|
267
|
-
publicKey: string,
|
|
268
|
-
signature: string,
|
|
269
|
-
message: string
|
|
270
|
-
): Promise<boolean> {
|
|
271
|
-
try {
|
|
272
|
-
// Decode base64 to bytes
|
|
273
|
-
const publicKeyBytes = base64ToBytes(publicKey);
|
|
274
|
-
const signatureBytes = base64ToBytes(signature);
|
|
275
|
-
|
|
276
|
-
// Encode message as UTF-8
|
|
277
|
-
const encoder = new TextEncoder();
|
|
278
|
-
const messageBytes = encoder.encode(message);
|
|
401
|
+
// ===== Tag Validation =====
|
|
279
402
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
console.error('Ed25519 signature verification failed:', err);
|
|
285
|
-
return false;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
403
|
+
// Tag validation constants
|
|
404
|
+
const TAG_MIN_LENGTH = 1;
|
|
405
|
+
const TAG_MAX_LENGTH = 64;
|
|
406
|
+
const TAG_REGEX = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
|
288
407
|
|
|
289
408
|
/**
|
|
290
|
-
* Validates a
|
|
291
|
-
*
|
|
409
|
+
* Validates a single tag format
|
|
410
|
+
* Rules: 1-64 chars, lowercase alphanumeric with optional dots/dashes
|
|
411
|
+
* Must start and end with alphanumeric character
|
|
412
|
+
*
|
|
413
|
+
* Valid examples: "chat", "video-call", "com.example.service", "v2"
|
|
414
|
+
* Invalid examples: "", "UPPERCASE", "-starts-dash", "ends-dash-"
|
|
292
415
|
*/
|
|
293
|
-
export
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
signature: string,
|
|
297
|
-
message: string
|
|
298
|
-
): Promise<{ valid: boolean; error?: string }> {
|
|
299
|
-
// Validate username format
|
|
300
|
-
const usernameCheck = validateUsername(username);
|
|
301
|
-
if (!usernameCheck.valid) {
|
|
302
|
-
return usernameCheck;
|
|
416
|
+
export function validateTag(tag: string): { valid: boolean; error?: string } {
|
|
417
|
+
if (typeof tag !== 'string') {
|
|
418
|
+
return { valid: false, error: 'Tag must be a string' };
|
|
303
419
|
}
|
|
304
420
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (parts.length !== 3 || parts[0] !== 'claim' || parts[1] !== username) {
|
|
308
|
-
return { valid: false, error: 'Invalid message format (expected: claim:{username}:{timestamp})' };
|
|
421
|
+
if (tag.length < TAG_MIN_LENGTH) {
|
|
422
|
+
return { valid: false, error: `Tag must be at least ${TAG_MIN_LENGTH} character` };
|
|
309
423
|
}
|
|
310
424
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
return { valid: false, error: 'Invalid timestamp in message' };
|
|
425
|
+
if (tag.length > TAG_MAX_LENGTH) {
|
|
426
|
+
return { valid: false, error: `Tag must be at most ${TAG_MAX_LENGTH} characters` };
|
|
314
427
|
}
|
|
315
428
|
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
429
|
+
// Single character tags just need to be alphanumeric
|
|
430
|
+
if (tag.length === 1) {
|
|
431
|
+
if (!/^[a-z0-9]$/.test(tag)) {
|
|
432
|
+
return { valid: false, error: 'Tag must be lowercase alphanumeric' };
|
|
433
|
+
}
|
|
434
|
+
return { valid: true };
|
|
320
435
|
}
|
|
321
436
|
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
return { valid: false, error: 'Invalid signature' };
|
|
437
|
+
// Multi-character tags must match the pattern
|
|
438
|
+
if (!TAG_REGEX.test(tag)) {
|
|
439
|
+
return { valid: false, error: 'Tag must be lowercase alphanumeric with optional dots/dashes, and start/end with alphanumeric' };
|
|
326
440
|
}
|
|
327
441
|
|
|
328
442
|
return { valid: true };
|
|
329
443
|
}
|
|
330
444
|
|
|
331
445
|
/**
|
|
332
|
-
* Validates
|
|
333
|
-
*
|
|
446
|
+
* Validates an array of tags
|
|
447
|
+
* @param tags Array of tags to validate
|
|
448
|
+
* @param maxTags Maximum number of tags allowed (default: 20)
|
|
334
449
|
*/
|
|
335
|
-
export
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
publicKey: string,
|
|
339
|
-
signature: string,
|
|
340
|
-
message: string
|
|
341
|
-
): Promise<{ valid: boolean; error?: string }> {
|
|
342
|
-
// Validate username format
|
|
343
|
-
const usernameCheck = validateUsername(username);
|
|
344
|
-
if (!usernameCheck.valid) {
|
|
345
|
-
return usernameCheck;
|
|
450
|
+
export function validateTags(tags: string[], maxTags: number = 20): { valid: boolean; error?: string } {
|
|
451
|
+
if (!Array.isArray(tags)) {
|
|
452
|
+
return { valid: false, error: 'Tags must be an array' };
|
|
346
453
|
}
|
|
347
454
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const parts = message.split(':');
|
|
351
|
-
if (parts.length < 4 || parts[0] !== 'publish' || parts[1] !== username) {
|
|
352
|
-
return { valid: false, error: 'Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})' };
|
|
455
|
+
if (tags.length === 0) {
|
|
456
|
+
return { valid: false, error: 'At least one tag is required' };
|
|
353
457
|
}
|
|
354
458
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (isNaN(timestamp)) {
|
|
358
|
-
return { valid: false, error: 'Invalid timestamp in message' };
|
|
459
|
+
if (tags.length > maxTags) {
|
|
460
|
+
return { valid: false, error: `Maximum ${maxTags} tags allowed` };
|
|
359
461
|
}
|
|
360
462
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
// Validate timestamp
|
|
368
|
-
const timestampCheck = validateTimestamp(timestamp);
|
|
369
|
-
if (!timestampCheck.valid) {
|
|
370
|
-
return timestampCheck;
|
|
463
|
+
// Validate each tag
|
|
464
|
+
for (let i = 0; i < tags.length; i++) {
|
|
465
|
+
const result = validateTag(tags[i]);
|
|
466
|
+
if (!result.valid) {
|
|
467
|
+
return { valid: false, error: `Tag ${i + 1}: ${result.error}` };
|
|
468
|
+
}
|
|
371
469
|
}
|
|
372
470
|
|
|
373
|
-
//
|
|
374
|
-
const
|
|
375
|
-
if (
|
|
376
|
-
return { valid: false, error: '
|
|
471
|
+
// Check for duplicates
|
|
472
|
+
const uniqueTags = new Set(tags);
|
|
473
|
+
if (uniqueTags.size !== tags.length) {
|
|
474
|
+
return { valid: false, error: 'Duplicate tags are not allowed' };
|
|
377
475
|
}
|
|
378
476
|
|
|
379
477
|
return { valid: true };
|