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 }