const encoder = new TextEncoder()
async function sha256Hex(data: BufferSource | string): Promise<string> {
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<ArrayBuffer> {
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<string, string>
body?: string
}
export async function signAwsRequest(input: {
method: string
url: string
region: string
service: string
accessKeyId: string
secretAccessKey: string
sessionToken?: string
headers?: Record<string, string>
body?: string
unsignedPayload?: boolean
now?: Date
}): Promise<SignedRequest> {
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<string, string> = {
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'
}