@xtr-dev/rondevu-server 0.5.0 → 0.5.6
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 +2755 -1448
- 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 +155 -9
- package/src/crypto.ts +362 -265
- package/src/index.ts +20 -25
- package/src/rpc.ts +658 -405
- package/src/storage/d1.ts +312 -263
- package/src/storage/factory.ts +69 -0
- package/src/storage/hash-id.ts +13 -9
- package/src/storage/memory.ts +559 -0
- package/src/storage/mysql.ts +588 -0
- package/src/storage/postgres.ts +595 -0
- package/src/storage/schemas/mysql.sql +59 -0
- package/src/storage/schemas/postgres.sql +64 -0
- package/src/storage/sqlite.ts +303 -269
- package/src/storage/types.ts +113 -113
- 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,380 +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
|
|
8
|
-
|
|
9
|
-
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
|
|
10
|
-
// Uses Web Crypto API (compatible with both Node.js and Cloudflare Workers)
|
|
11
|
-
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
|
|
12
|
-
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
|
|
13
|
-
};
|
|
6
|
+
import { Buffer } from 'node:buffer';
|
|
14
7
|
|
|
15
8
|
// Username validation
|
|
16
|
-
|
|
17
|
-
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;
|
|
18
12
|
const USERNAME_MAX_LENGTH = 32;
|
|
19
13
|
|
|
20
|
-
// Timestamp validation (5 minutes tolerance)
|
|
21
|
-
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
|
|
22
|
-
|
|
23
14
|
/**
|
|
24
|
-
* Generates
|
|
25
|
-
* Format:
|
|
26
|
-
*
|
|
15
|
+
* Generates a random credential name
|
|
16
|
+
* Format: {adjective}-{noun}-{random}
|
|
17
|
+
* Example: "brave-tiger-7a3f2b1c9d8e", "quick-river-9b2e4c1a5f3d"
|
|
27
18
|
*/
|
|
28
|
-
export function
|
|
29
|
-
const
|
|
30
|
-
|
|
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));
|
|
31
40
|
const hex = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
32
|
-
return `anon-${timestamp}-${hex}`;
|
|
33
|
-
}
|
|
34
41
|
|
|
35
|
-
|
|
36
|
-
* Convert Uint8Array to base64 string
|
|
37
|
-
*/
|
|
38
|
-
function bytesToBase64(bytes: Uint8Array): string {
|
|
39
|
-
const binString = Array.from(bytes, (byte) =>
|
|
40
|
-
String.fromCodePoint(byte)
|
|
41
|
-
).join('');
|
|
42
|
-
return btoa(binString);
|
|
42
|
+
return `${adjective}-${noun}-${hex}`;
|
|
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
|
-
const
|
|
50
|
-
|
|
51
|
-
}
|
|
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('');
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* Validates that the message contains the expected username and has a valid timestamp
|
|
57
|
-
*/
|
|
58
|
-
export function validateAuthMessage(
|
|
59
|
-
expectedUsername: string,
|
|
60
|
-
message: string
|
|
61
|
-
): { valid: boolean; error?: string } {
|
|
62
|
-
const parts = message.split(':');
|
|
63
|
-
|
|
64
|
-
if (parts.length < 3) {
|
|
65
|
-
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');
|
|
66
57
|
}
|
|
67
58
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return { valid: false, error: 'Username in message does not match authenticated username' };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Validate timestamp
|
|
78
|
-
if (isNaN(timestamp)) {
|
|
79
|
-
return { valid: false, error: 'Invalid timestamp in message' };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const timestampCheck = validateTimestamp(timestamp);
|
|
83
|
-
if (!timestampCheck.valid) {
|
|
84
|
-
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
|
+
}
|
|
85
65
|
}
|
|
86
66
|
|
|
87
|
-
return
|
|
67
|
+
return secret;
|
|
88
68
|
}
|
|
89
69
|
|
|
90
|
-
// =====
|
|
70
|
+
// ===== Secret Encryption/Decryption (Database Storage) =====
|
|
91
71
|
|
|
92
72
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
73
|
+
* Convert hex string to byte array with validation
|
|
74
|
+
* @param hex Hex string (must be even length)
|
|
75
|
+
* @returns Uint8Array of bytes
|
|
95
76
|
*/
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
77
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
78
|
+
if (hex.length % 2 !== 0) {
|
|
79
|
+
throw new Error('Hex string must have even length');
|
|
99
80
|
}
|
|
100
81
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|
|
103
89
|
}
|
|
104
90
|
|
|
105
|
-
|
|
106
|
-
|
|
91
|
+
const match = hex.match(/.{1,2}/g);
|
|
92
|
+
if (!match) {
|
|
93
|
+
throw new Error('Invalid hex string format');
|
|
107
94
|
}
|
|
108
95
|
|
|
109
|
-
|
|
110
|
-
|
|
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)');
|
|
111
117
|
}
|
|
112
118
|
|
|
113
|
-
|
|
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}`;
|
|
114
152
|
}
|
|
115
153
|
|
|
116
154
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
|
121
160
|
*/
|
|
122
|
-
export function
|
|
123
|
-
|
|
124
|
-
|
|
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)');
|
|
125
165
|
}
|
|
126
166
|
|
|
127
|
-
// Parse
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
130
|
-
|
|
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)');
|
|
131
171
|
}
|
|
132
172
|
|
|
133
|
-
const
|
|
173
|
+
const [ivHex, ciphertextHex] = parts;
|
|
134
174
|
|
|
135
|
-
// Validate
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
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)');
|
|
139
178
|
}
|
|
140
179
|
|
|
141
|
-
|
|
142
|
-
|
|
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)');
|
|
143
184
|
}
|
|
144
185
|
|
|
145
|
-
//
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|
|
150
213
|
|
|
151
|
-
|
|
152
|
-
if (username) {
|
|
153
|
-
const usernameCheck = validateUsername(username);
|
|
154
|
-
if (!usernameCheck.valid) {
|
|
155
|
-
return usernameCheck;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
214
|
+
// ===== HMAC Signature Generation and Verification =====
|
|
158
215
|
|
|
159
|
-
|
|
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');
|
|
160
246
|
}
|
|
161
247
|
|
|
162
248
|
/**
|
|
163
|
-
*
|
|
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
|
|
164
256
|
*/
|
|
165
|
-
export function
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
}
|
|
175
286
|
}
|
|
176
287
|
|
|
177
288
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
289
|
+
* Canonical JSON serialization with sorted keys
|
|
290
|
+
* Ensures deterministic output regardless of property insertion order
|
|
291
|
+
* Must match client's canonicalJSON implementation exactly
|
|
180
292
|
*/
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
const avail = parseVersion(available);
|
|
293
|
+
function canonicalJSON(obj: any, depth: number = 0): string {
|
|
294
|
+
const MAX_DEPTH = 100;
|
|
184
295
|
|
|
185
|
-
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);
|
|
186
302
|
|
|
187
|
-
|
|
188
|
-
if (req.major !== avail.major) return false;
|
|
303
|
+
const type = typeof obj;
|
|
189
304
|
|
|
190
|
-
|
|
191
|
-
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');
|
|
192
308
|
|
|
193
|
-
|
|
194
|
-
if (avail.minor < req.minor) return false;
|
|
195
|
-
if (avail.minor === req.minor && avail.patch < req.patch) return false;
|
|
309
|
+
if (type !== 'object') return JSON.stringify(obj);
|
|
196
310
|
|
|
197
|
-
|
|
198
|
-
|
|
311
|
+
if (Array.isArray(obj)) {
|
|
312
|
+
return '[' + obj.map(item => canonicalJSON(item, depth + 1)).join(',') + ']';
|
|
313
|
+
}
|
|
199
314
|
|
|
200
|
-
|
|
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(',') + '}';
|
|
201
318
|
}
|
|
202
319
|
|
|
203
320
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
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
|
|
208
332
|
*/
|
|
209
|
-
export function
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|
|
216
342
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
username = fqn.substring(atIndex + 1);
|
|
221
|
-
} else {
|
|
222
|
-
// Format: service:version (no username)
|
|
223
|
-
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())');
|
|
224
346
|
}
|
|
225
347
|
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|
|
229
352
|
|
|
230
|
-
|
|
231
|
-
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
|
+
}
|
|
232
358
|
|
|
233
|
-
|
|
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
|
+
}
|
|
234
367
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
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}`;
|
|
240
373
|
}
|
|
241
374
|
|
|
375
|
+
// ===== Username Validation =====
|
|
376
|
+
|
|
242
377
|
/**
|
|
243
|
-
* Validates
|
|
378
|
+
* Validates username format
|
|
379
|
+
* Rules: 4-32 chars, lowercase alphanumeric + dashes + periods, must start/end with alphanumeric
|
|
244
380
|
*/
|
|
245
|
-
export function
|
|
246
|
-
if (typeof
|
|
247
|
-
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' };
|
|
248
384
|
}
|
|
249
385
|
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
}
|
|
252
393
|
|
|
253
|
-
if (
|
|
254
|
-
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' };
|
|
255
396
|
}
|
|
256
397
|
|
|
257
398
|
return { valid: true };
|
|
258
399
|
}
|
|
259
400
|
|
|
260
|
-
|
|
261
|
-
* Verifies Ed25519 signature
|
|
262
|
-
* @param publicKey Base64-encoded Ed25519 public key (32 bytes)
|
|
263
|
-
* @param signature Base64-encoded Ed25519 signature (64 bytes)
|
|
264
|
-
* @param message Message that was signed (UTF-8 string)
|
|
265
|
-
* @returns true if signature is valid, false otherwise
|
|
266
|
-
*/
|
|
267
|
-
export async function verifyEd25519Signature(
|
|
268
|
-
publicKey: string,
|
|
269
|
-
signature: string,
|
|
270
|
-
message: string
|
|
271
|
-
): Promise<boolean> {
|
|
272
|
-
try {
|
|
273
|
-
// Decode base64 to bytes
|
|
274
|
-
const publicKeyBytes = base64ToBytes(publicKey);
|
|
275
|
-
const signatureBytes = base64ToBytes(signature);
|
|
276
|
-
|
|
277
|
-
// Encode message as UTF-8
|
|
278
|
-
const encoder = new TextEncoder();
|
|
279
|
-
const messageBytes = encoder.encode(message);
|
|
401
|
+
// ===== Tag Validation =====
|
|
280
402
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
console.error('Ed25519 signature verification failed:', err);
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
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])?$/;
|
|
289
407
|
|
|
290
408
|
/**
|
|
291
|
-
* Validates a
|
|
292
|
-
*
|
|
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-"
|
|
293
415
|
*/
|
|
294
|
-
export
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
signature: string,
|
|
298
|
-
message: string
|
|
299
|
-
): Promise<{ valid: boolean; error?: string }> {
|
|
300
|
-
// Validate username format
|
|
301
|
-
const usernameCheck = validateUsername(username);
|
|
302
|
-
if (!usernameCheck.valid) {
|
|
303
|
-
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' };
|
|
304
419
|
}
|
|
305
420
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (parts.length !== 3 || parts[0] !== 'claim' || parts[1] !== username) {
|
|
309
|
-
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` };
|
|
310
423
|
}
|
|
311
424
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
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` };
|
|
315
427
|
}
|
|
316
428
|
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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 };
|
|
321
435
|
}
|
|
322
436
|
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
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' };
|
|
327
440
|
}
|
|
328
441
|
|
|
329
442
|
return { valid: true };
|
|
330
443
|
}
|
|
331
444
|
|
|
332
445
|
/**
|
|
333
|
-
* Validates
|
|
334
|
-
*
|
|
446
|
+
* Validates an array of tags
|
|
447
|
+
* @param tags Array of tags to validate
|
|
448
|
+
* @param maxTags Maximum number of tags allowed (default: 20)
|
|
335
449
|
*/
|
|
336
|
-
export
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
publicKey: string,
|
|
340
|
-
signature: string,
|
|
341
|
-
message: string
|
|
342
|
-
): Promise<{ valid: boolean; error?: string }> {
|
|
343
|
-
// Validate username format
|
|
344
|
-
const usernameCheck = validateUsername(username);
|
|
345
|
-
if (!usernameCheck.valid) {
|
|
346
|
-
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' };
|
|
347
453
|
}
|
|
348
454
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const parts = message.split(':');
|
|
352
|
-
if (parts.length < 4 || parts[0] !== 'publish' || parts[1] !== username) {
|
|
353
|
-
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' };
|
|
354
457
|
}
|
|
355
458
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (isNaN(timestamp)) {
|
|
359
|
-
return { valid: false, error: 'Invalid timestamp in message' };
|
|
459
|
+
if (tags.length > maxTags) {
|
|
460
|
+
return { valid: false, error: `Maximum ${maxTags} tags allowed` };
|
|
360
461
|
}
|
|
361
462
|
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
// Validate timestamp
|
|
369
|
-
const timestampCheck = validateTimestamp(timestamp);
|
|
370
|
-
if (!timestampCheck.valid) {
|
|
371
|
-
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
|
+
}
|
|
372
469
|
}
|
|
373
470
|
|
|
374
|
-
//
|
|
375
|
-
const
|
|
376
|
-
if (
|
|
377
|
-
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' };
|
|
378
475
|
}
|
|
379
476
|
|
|
380
477
|
return { valid: true };
|