* Creates an Express middleware that verifies the ed25519 signature on every request. * * The signature header format is: `t= ,v1= ` * * The signed message is constructed as: * - GET requests: ` [ ]` * - Oth
(publicKey: string, toleranceSeconds: number)
| 313 | * `authorizationToken` is the bearer token value from the `Authorization` header (if present). |
| 314 | */ |
| 315 | function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) { |
| 316 | // Throttle invalid-signature warnings to at most once per 60 seconds. |
| 317 | let lastInvalidSigWarnAt = 0; |
| 318 | const WARN_THROTTLE_SECS = 60; |
| 319 | |
| 320 | function warnInvalidSignature() { |
| 321 | const now = Math.floor(Date.now() / 1000); |
| 322 | if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) { |
| 323 | lastInvalidSigWarnAt = now; |
| 324 | console.warn( |
| 325 | colors.yellow( |
| 326 | 'Warning: Received a request with an invalid signature. ' + |
| 327 | 'Please double-check whether you have the correct public API key configured.', |
| 328 | ), |
| 329 | ); |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | return (req: express.Request, res: express.Response, next: express.NextFunction) => { |
| 334 | const signatureHeader = req.headers['x-zenstack-signature']; |
| 335 | if (!signatureHeader || typeof signatureHeader !== 'string') { |
| 336 | return rejectAuth(res, 'MISSING_SIGNATURE_HEADER'); |
| 337 | } |
| 338 | |
| 339 | const parts = signatureHeader.split(','); |
| 340 | const timestampPart = parts.find((p) => p.startsWith('t=')); |
| 341 | const sigPart = parts.find((p) => p.startsWith('v1=')); |
| 342 | if (!timestampPart || !sigPart) { |
| 343 | return rejectAuth(res, 'INVALID_SIGNATURE_FORMAT'); |
| 344 | } |
| 345 | const timestamp = timestampPart.substring(2); |
| 346 | const sig = sigPart.substring(3); |
| 347 | |
| 348 | // Replay-attack prevention: reject requests whose timestamp deviates |
| 349 | // from server time by more than the configured tolerance. |
| 350 | const requestTime = parseInt(timestamp, 10); |
| 351 | const now = Math.floor(Date.now() / 1000); |
| 352 | if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) { |
| 353 | return rejectAuth(res, 'INVALID_TIMESTAMP'); |
| 354 | } |
| 355 | |
| 356 | // Payload: raw query string for GET/DELETE, raw body for other methods. |
| 357 | let payload: string; |
| 358 | if (req.method === 'GET' || req.method === 'DELETE') { |
| 359 | const qMark = req.originalUrl.indexOf('?'); |
| 360 | payload = qMark >= 0 ? req.originalUrl.substring(qMark + 1) : ''; |
| 361 | } else { |
| 362 | payload = (req as express.Request & { rawBody?: string }).rawBody ?? ''; |
| 363 | } |
| 364 | |
| 365 | // authorizationToken is the bearer token value (if present). |
| 366 | const authHeader = req.headers['authorization']; |
| 367 | const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined; |
| 368 | |
| 369 | const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`; |
| 370 | |
| 371 | try { |
| 372 | const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url')); |
no test coverage detected