conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }