conductor

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

sigv4.js (6032B)


      1 // src/lib/storage/sigv4.js - AWS Signature Version 4 signing
      2 //
      3 // Only what an S3 compatible object store needs: PUT, GET, HEAD, DELETE and
      4 // presigned URLs. Written against node:crypto rather than pulling in the AWS
      5 // SDK, which would add roughly a hundred transitive packages for four verbs.
      6 //
      7 // Verified against the published AWS test vectors, see test/sigv4.test.js.
      8 
      9 import crypto from 'node:crypto';
     10 
     11 const ALGORITHM = 'AWS4-HMAC-SHA256';
     12 export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
     13 export const EMPTY_SHA256 = crypto.createHash('sha256').update('').digest('hex');
     14 
     15 function hmac(key, data) {
     16   return crypto.createHmac('sha256', key).update(data, 'utf8').digest();
     17 }
     18 
     19 export function sha256Hex(data) {
     20   return crypto.createHash('sha256').update(data).digest('hex');
     21 }
     22 
     23 // RFC 3986 encoding. encodeURIComponent leaves ! ' ( ) * alone, which AWS
     24 // expects to be percent encoded.
     25 export function uriEncode(str, encodeSlash = true) {
     26   let out = '';
     27   for (const ch of String(str)) {
     28     if (/[A-Za-z0-9\-._~]/.test(ch)) {
     29       out += ch;
     30     } else if (ch === '/') {
     31       out += encodeSlash ? '%2F' : '/';
     32     } else {
     33       for (const byte of Buffer.from(ch, 'utf8')) {
     34         out += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
     35       }
     36     }
     37   }
     38   return out;
     39 }
     40 
     41 // 20150830T123600Z and 20150830
     42 export function amzDate(date = new Date()) {
     43   const iso = date.toISOString().replace(/[:-]|\.\d{3}/g, '');
     44   return { amz: iso, stamp: iso.slice(0, 8) };
     45 }
     46 
     47 function canonicalQuery(searchParams) {
     48   const pairs = [];
     49   for (const [k, v] of searchParams) pairs.push([uriEncode(k), uriEncode(v)]);
     50   // Sort by encoded key, then encoded value.
     51   pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
     52   return pairs.map(([k, v]) => `${k}=${v}`).join('&');
     53 }
     54 
     55 // The canonical URI is the request path exactly as it goes on the wire.
     56 // Callers must therefore hand in a URL whose path is already RFC 3986
     57 // encoded, which is what uriEncode(key, false) produces. Encoding here
     58 // instead would double encode, turning a %20 into %25 20 and yielding
     59 // SignatureDoesNotMatch for any key containing a space or a plus.
     60 //
     61 // Note this is correct for S3 specifically. Other AWS services expect the
     62 // path to be normalized and encoded a second time.
     63 function canonicalPath(pathname) {
     64   return pathname === '' ? '/' : pathname;
     65 }
     66 
     67 function signingKey(secretAccessKey, stamp, region, service) {
     68   const kDate = hmac(`AWS4${secretAccessKey}`, stamp);
     69   const kRegion = hmac(kDate, region);
     70   const kService = hmac(kRegion, service);
     71   return hmac(kService, 'aws4_request');
     72 }
     73 
     74 function buildCanonical({ method, url, headers, payloadHash }) {
     75   const lowered = Object.entries(headers)
     76     .map(([k, v]) => [k.toLowerCase(), String(v).trim().replace(/\s+/g, ' ')])
     77     .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
     78 
     79   const canonicalHeaders = lowered.map(([k, v]) => `${k}:${v}\n`).join('');
     80   const signedHeaders = lowered.map(([k]) => k).join(';');
     81 
     82   const canonicalRequest = [
     83     method.toUpperCase(),
     84     canonicalPath(url.pathname),
     85     canonicalQuery(url.searchParams),
     86     canonicalHeaders,
     87     signedHeaders,
     88     payloadHash,
     89   ].join('\n');
     90 
     91   return { canonicalRequest, signedHeaders };
     92 }
     93 
     94 // Signs a request and returns the headers to send, including Authorization.
     95 export function signRequest(opts) {
     96   const {
     97     method,
     98     url,
     99     headers = {},
    100     payloadHash = EMPTY_SHA256,
    101     region,
    102     service = 's3',
    103     accessKeyId,
    104     secretAccessKey,
    105     date = new Date(),
    106   } = opts;
    107 
    108   const target = url instanceof URL ? url : new URL(url);
    109   const { amz, stamp } = amzDate(date);
    110 
    111   const signedHeaderSet = {
    112     ...headers,
    113     host: target.host,
    114     'x-amz-date': amz,
    115   };
    116   if (service === 's3') signedHeaderSet['x-amz-content-sha256'] = payloadHash;
    117 
    118   const { canonicalRequest, signedHeaders } = buildCanonical({
    119     method,
    120     url: target,
    121     headers: signedHeaderSet,
    122     payloadHash,
    123   });
    124 
    125   const scope = `${stamp}/${region}/${service}/aws4_request`;
    126   const stringToSign = [ALGORITHM, amz, scope, sha256Hex(canonicalRequest)].join('\n');
    127   const signature = crypto
    128     .createHmac('sha256', signingKey(secretAccessKey, stamp, region, service))
    129     .update(stringToSign, 'utf8')
    130     .digest('hex');
    131 
    132   return {
    133     ...signedHeaderSet,
    134     authorization:
    135       `${ALGORITHM} Credential=${accessKeyId}/${scope}, ` +
    136       `SignedHeaders=${signedHeaders}, Signature=${signature}`,
    137   };
    138 }
    139 
    140 // Produces a presigned URL, where the signature travels in the query string
    141 // and no Authorization header is needed.
    142 export function presignUrl(opts) {
    143   const {
    144     method = 'GET',
    145     url,
    146     headers = {},
    147     expires = 3600,
    148     region,
    149     service = 's3',
    150     accessKeyId,
    151     secretAccessKey,
    152     date = new Date(),
    153   } = opts;
    154 
    155   const target = new URL(url instanceof URL ? url.toString() : url);
    156   const { amz, stamp } = amzDate(date);
    157   const scope = `${stamp}/${region}/${service}/aws4_request`;
    158 
    159   // Only host is signed, so the URL works from any client.
    160   const signedHeaderSet = { ...headers, host: target.host };
    161   const signedHeaders = Object.keys(signedHeaderSet)
    162     .map((k) => k.toLowerCase())
    163     .sort()
    164     .join(';');
    165 
    166   target.searchParams.set('X-Amz-Algorithm', ALGORITHM);
    167   target.searchParams.set('X-Amz-Credential', `${accessKeyId}/${scope}`);
    168   target.searchParams.set('X-Amz-Date', amz);
    169   target.searchParams.set('X-Amz-Expires', String(expires));
    170   target.searchParams.set('X-Amz-SignedHeaders', signedHeaders);
    171 
    172   const { canonicalRequest } = buildCanonical({
    173     method,
    174     url: target,
    175     headers: signedHeaderSet,
    176     payloadHash: UNSIGNED_PAYLOAD,
    177   });
    178 
    179   const stringToSign = [ALGORITHM, amz, scope, sha256Hex(canonicalRequest)].join('\n');
    180   const signature = crypto
    181     .createHmac('sha256', signingKey(secretAccessKey, stamp, region, service))
    182     .update(stringToSign, 'utf8')
    183     .digest('hex');
    184 
    185   target.searchParams.set('X-Amz-Signature', signature);
    186   return target.toString();
    187 }