const encoder = new TextEncoder() async function sha256Hex(data: BufferSource | string): Promise { const bytes = typeof data === 'string' ? encoder.encode(data) : data const digest = await crypto.subtle.digest('SHA-256', bytes) return hex(new Uint8Array(digest)) } async function hmac( key: BufferSource, data: string, ): Promise { const cryptoKey = await crypto.subtle.importKey( 'raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ) return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data)) } function hex(bytes: Uint8Array): string { return Array.from(bytes) .map((byte) => byte.toString(16).padStart(2, '0')) .join('') } function amzDate(now: Date): { amzDate: string; dateStamp: string } { const iso = now.toISOString().replace(/[:-]|\.\d{3}/g, '') return { amzDate: iso, dateStamp: iso.slice(0, 8), } } function canonicalQuery(url: URL): string { const pairs: Array<[string, string]> = [] for (const [key, value] of url.searchParams.entries()) { pairs.push([ encodeURIComponent(key).replace(/%7E/g, '~'), encodeURIComponent(value).replace(/%7E/g, '~'), ]) } pairs.sort(([left], [right]) => left.localeCompare(right)) return pairs.map(([key, value]) => key + '=' + value).join('&') } function canonicalPath(pathname: string, service: string): string { if (service === 's3') { return pathname .split('/') .map((part) => encodeURIComponent(decodeURIComponent(part))) .join('/') .replace(/%2F/g, '/') } return pathname || '/' } export type SignedRequest = { url: string method: string headers: Record body?: string } export async function signAwsRequest(input: { method: string url: string region: string service: string accessKeyId: string secretAccessKey: string sessionToken?: string headers?: Record body?: string unsignedPayload?: boolean now?: Date }): Promise { const url = new URL(input.url) const method = input.method.toUpperCase() const { amzDate: date, dateStamp } = amzDate(input.now ?? new Date()) const payload = input.body ?? '' const payloadHash = input.unsignedPayload ? 'UNSIGNED-PAYLOAD' : await sha256Hex(payload) const headers: Record = { host: url.host, 'x-amz-date': date, 'x-amz-content-sha256': payloadHash, ...(input.headers ?? {}), } if (input.sessionToken) { headers['x-amz-security-token'] = input.sessionToken } if (payload && !headers['content-type']) { headers['content-type'] = 'application/x-www-form-urlencoded' } const signedHeaderNames = Object.keys(headers) .map((name) => name.toLowerCase()) .sort() const canonicalHeaders = signedHeaderNames .map((name) => { const value = headers[name] ?? headers[Object.keys(headers).find((key) => key.toLowerCase() === name) ?? ''] return name + ':' + String(value).trim().replace(/\s+/g, ' ') }) .join('\n') + '\n' const signedHeaders = signedHeaderNames.join(';') const canonicalRequest = [ method, canonicalPath(url.pathname || '/', input.service), canonicalQuery(url), canonicalHeaders, signedHeaders, payloadHash, ].join('\n') const credentialScope = dateStamp + '/' + input.region + '/' + input.service + '/aws4_request' const stringToSign = [ 'AWS4-HMAC-SHA256', date, credentialScope, await sha256Hex(canonicalRequest), ].join('\n') const kDate = await hmac(encoder.encode('AWS4' + input.secretAccessKey), dateStamp) const kRegion = await hmac(kDate, input.region) const kService = await hmac(kRegion, input.service) const kSigning = await hmac(kService, 'aws4_request') const signature = hex(new Uint8Array(await hmac(kSigning, stringToSign))) headers.authorization = 'AWS4-HMAC-SHA256 Credential=' + input.accessKeyId + '/' + credentialScope + ', SignedHeaders=' + signedHeaders + ', Signature=' + signature return { url: url.toString(), method, headers, body: payload || undefined, } } export function assertAmazonAwsHost(hostname: string) { if ( hostname !== 'amazonaws.com' && !hostname.endsWith('.amazonaws.com') && hostname !== 'amazonaws.com.cn' && !hostname.endsWith('.amazonaws.com.cn') ) { throw new Error( 'AWS requests must target an amazonaws.com (or amazonaws.com.cn) host.', ) } } export function serviceHost(service: string, region: string): string { if (service === 'sts' && region === 'us-east-1') return 'sts.amazonaws.com' if (service === 's3' && region === 'us-east-1') return 's3.us-east-1.amazonaws.com' if (service === 'logs') return 'logs.' + region + '.amazonaws.com' if (service === 'monitoring') return 'monitoring.' + region + '.amazonaws.com' if (service === 's3') return 's3.' + region + '.amazonaws.com' if (service === 'sts') return 'sts.' + region + '.amazonaws.com' return service + '.' + region + '.amazonaws.com' }