Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/aws

src/sign.ts

174 lines · 4.8 KB · TypeScript
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'
}