signature.js (1584B)
1 // src/lib/signature.js - checking a request against a project's trigger secret 2 // 3 // Shared because the same secret authenticates both starting a job and 4 // reading one back, and a comparison this easy to get subtly wrong should 5 // exist once rather than once per caller. 6 // 7 // Two shapes are accepted, matching what the forges actually send: 8 // GitHub and Gitea sign the body, GitLab presents the secret itself. 9 // 10 // The HMAC is computed over the exact bytes received, which is why callers 11 // keep the raw body around rather than re-serializing the parsed one. A GET 12 // carries no body, so its signature is an HMAC over zero bytes. 13 14 import crypto from 'node:crypto'; 15 16 export function verifySignature(req, secret) { 17 const raw = req.rawBody ?? Buffer.alloc(0); 18 19 // GitHub and Gitea: sha256=<hex> over the body. 20 const hubSignature = req.headers['x-hub-signature-256']; 21 if (typeof hubSignature === 'string' && hubSignature.length > 0) { 22 const expected = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`; 23 return timingSafeEqual(expected, hubSignature); 24 } 25 26 // GitLab: the secret itself, compared rather than signed. 27 const gitlabToken = req.headers['x-gitlab-token']; 28 if (typeof gitlabToken === 'string' && gitlabToken.length > 0) { 29 return timingSafeEqual(secret, gitlabToken); 30 } 31 32 return false; 33 } 34 35 export function timingSafeEqual(a, b) { 36 const left = Buffer.from(String(a), 'utf8'); 37 const right = Buffer.from(String(b), 'utf8'); 38 if (left.length !== right.length) return false; 39 return crypto.timingSafeEqual(left, right); 40 }