mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
603 lines
18 KiB
TypeScript
603 lines
18 KiB
TypeScript
// this file is migrated from https://github.com/mhart/aws4fetch
|
|
|
|
// use forge to create HMAC and SHA256 hashes
|
|
// because crypto.subtle is not available in non-secure contexts
|
|
// https://developer.mozilla.org/zh-CN/docs/Web/API/SubtleCrypto
|
|
import forge from "node-forge"
|
|
|
|
/**
|
|
* @license MIT <https://opensource.org/licenses/MIT>
|
|
* @copyright Michael Hart 2024
|
|
*/
|
|
const encoder = new TextEncoder()
|
|
|
|
const HOST_SERVICES: Record<string, string> = {
|
|
appstream2: "appstream",
|
|
cloudhsmv2: "cloudhsm",
|
|
email: "ses",
|
|
marketplace: "aws-marketplace",
|
|
mobile: "AWSMobileHubService",
|
|
pinpoint: "mobiletargeting",
|
|
queue: "sqs",
|
|
"git-codecommit": "codecommit",
|
|
"mturk-requester-sandbox": "mturk-requester",
|
|
"personalize-runtime": "personalize",
|
|
}
|
|
|
|
// https://github.com/aws/aws-sdk-js/blob/cc29728c1c4178969ebabe3bbe6b6f3159436394/lib/signers/v4.js#L190-L198
|
|
const UNSIGNABLE_HEADERS = new Set([
|
|
"authorization",
|
|
"content-type",
|
|
"content-length",
|
|
"user-agent",
|
|
"presigned-expires",
|
|
"expect",
|
|
"x-amzn-trace-id",
|
|
"range",
|
|
"connection",
|
|
])
|
|
|
|
export class AwsClient {
|
|
accessKeyId: string
|
|
secretAccessKey: string
|
|
sessionToken?: string
|
|
service?: string
|
|
region?: string
|
|
cache: Map<string, ArrayBuffer>
|
|
retries: number
|
|
initRetryMs: number
|
|
|
|
/**
|
|
* @param {{
|
|
* accessKeyId: string
|
|
* secretAccessKey: string
|
|
* sessionToken?: string
|
|
* service?: string
|
|
* region?: string
|
|
* cache?: Map<string, ArrayBuffer>
|
|
* retries?: number
|
|
* initRetryMs?: number
|
|
* }} options
|
|
*/
|
|
constructor({
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
sessionToken,
|
|
service,
|
|
region,
|
|
cache,
|
|
retries,
|
|
initRetryMs,
|
|
}: {
|
|
accessKeyId: string
|
|
secretAccessKey: string
|
|
sessionToken?: string
|
|
service?: string
|
|
region?: string
|
|
cache?: Map<string, ArrayBuffer>
|
|
retries?: number
|
|
initRetryMs?: number
|
|
}) {
|
|
if (accessKeyId == null) throw new TypeError("accessKeyId is a required option")
|
|
if (secretAccessKey == null) throw new TypeError("secretAccessKey is a required option")
|
|
this.accessKeyId = accessKeyId
|
|
this.secretAccessKey = secretAccessKey
|
|
this.sessionToken = sessionToken
|
|
this.service = service
|
|
this.region = region
|
|
/** @type {Map<string, ArrayBuffer>} */
|
|
this.cache = cache || new Map()
|
|
this.retries = retries != null ? retries : 0 // Up to 25.6 secs
|
|
this.initRetryMs = initRetryMs || 50
|
|
}
|
|
|
|
/**
|
|
* @typedef {RequestInit & {
|
|
* aws?: {
|
|
* accessKeyId?: string
|
|
* secretAccessKey?: string
|
|
* sessionToken?: string
|
|
* service?: string
|
|
* region?: string
|
|
* cache?: Map<string, ArrayBuffer>
|
|
* datetime?: string
|
|
* signQuery?: boolean
|
|
* appendSessionToken?: boolean
|
|
* allHeaders?: boolean
|
|
* singleEncode?: boolean
|
|
* }
|
|
* }} AwsRequestInit
|
|
*
|
|
* @param {Request | { toString: () => string }} input
|
|
* @param {?AwsRequestInit} [init]
|
|
* @returns {Promise<Request>}
|
|
*/
|
|
async sign(
|
|
input: string | Request,
|
|
init?: RequestInit & {
|
|
body?: BodyInit | ArrayBuffer | ReadableStream<Uint8Array<ArrayBufferLike>> | null
|
|
aws?: Record<string, unknown>
|
|
},
|
|
) {
|
|
if (input instanceof Request) {
|
|
const { method, url, headers, body } = input
|
|
init = Object.assign({ method, url, headers }, init)
|
|
if (init.body == null && headers.has("Content-Type")) {
|
|
init.body = body != null && headers.has("X-Amz-Content-Sha256") ? body : await input.clone().arrayBuffer()
|
|
}
|
|
input = url
|
|
}
|
|
const signer = new AwsV4Signer(Object.assign({ url: input.toString() }, init, this, init && init.aws))
|
|
const signed = Object.assign({}, init, await signer.sign())
|
|
delete signed.aws
|
|
try {
|
|
return new Request(signed.url.toString(), signed)
|
|
} catch (e) {
|
|
if (e instanceof TypeError) {
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=1360943
|
|
return new Request(signed.url.toString(), Object.assign({ duplex: "half" }, signed))
|
|
}
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Request | { toString: () => string }} input
|
|
* @param {?AwsRequestInit} [init]
|
|
* @returns {Promise<Response>}
|
|
*/
|
|
async fetch(input: string | Request, init?: RequestInit & { body?: BodyInit | null; aws?: Record<string, unknown> }) {
|
|
for (let i = 0; i <= this.retries; i++) {
|
|
const fetched = fetch(await this.sign(input, init))
|
|
if (i === this.retries) {
|
|
return fetched // No need to await if we're returning anyway
|
|
}
|
|
const res = await fetched
|
|
if (res.status < 500 && res.status !== 429) {
|
|
return res
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i)))
|
|
}
|
|
throw new Error("An unknown error occurred, ensure retries is not negative")
|
|
}
|
|
}
|
|
|
|
export class AwsV4Signer {
|
|
method: string
|
|
url: URL
|
|
headers: Headers
|
|
body: BodyInit | null | undefined
|
|
accessKeyId: string
|
|
secretAccessKey: string
|
|
sessionToken: string | undefined
|
|
service: string
|
|
region: string
|
|
cache: Map<string, ArrayBuffer>
|
|
datetime: string
|
|
signQuery: boolean | undefined
|
|
appendSessionToken: boolean
|
|
signableHeaders: string[]
|
|
signedHeaders: string
|
|
canonicalHeaders: string
|
|
credentialString: string
|
|
encodedPath: string
|
|
encodedSearch: string
|
|
/**
|
|
* @param {{
|
|
* method?: string
|
|
* url: string
|
|
* headers?: HeadersInit
|
|
* body?: BodyInit | null
|
|
* accessKeyId: string
|
|
* secretAccessKey: string
|
|
* sessionToken?: string
|
|
* service?: string
|
|
* region?: string
|
|
* cache?: Map<string, ArrayBuffer>
|
|
* datetime?: string
|
|
* signQuery?: boolean
|
|
* appendSessionToken?: boolean
|
|
* allHeaders?: boolean
|
|
* singleEncode?: boolean
|
|
* }} options
|
|
*/
|
|
constructor({
|
|
method,
|
|
url,
|
|
headers,
|
|
body,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
sessionToken,
|
|
service,
|
|
region,
|
|
cache,
|
|
datetime,
|
|
signQuery,
|
|
appendSessionToken,
|
|
allHeaders,
|
|
singleEncode,
|
|
}: {
|
|
method?: string
|
|
url: string
|
|
headers?: HeadersInit
|
|
body?: BodyInit | null
|
|
accessKeyId: string
|
|
secretAccessKey: string
|
|
sessionToken?: string
|
|
service?: string
|
|
region?: string
|
|
cache?: Map<string, ArrayBuffer>
|
|
datetime?: string
|
|
signQuery?: boolean
|
|
appendSessionToken?: boolean
|
|
allHeaders?: boolean
|
|
singleEncode?: boolean
|
|
}) {
|
|
if (url == null) throw new TypeError("url is a required option")
|
|
if (accessKeyId == null) throw new TypeError("accessKeyId is a required option")
|
|
if (secretAccessKey == null) throw new TypeError("secretAccessKey is a required option")
|
|
|
|
this.method = method || (body ? "POST" : "GET")
|
|
this.url = new URL(url)
|
|
this.headers = new Headers(headers || {})
|
|
this.body = body
|
|
|
|
this.accessKeyId = accessKeyId
|
|
this.secretAccessKey = secretAccessKey
|
|
this.sessionToken = sessionToken
|
|
|
|
let guessedService, guessedRegion
|
|
if (!service || !region) {
|
|
;[guessedService, guessedRegion] = guessServiceRegion(this.url, this.headers)
|
|
}
|
|
this.service = service || guessedService || ""
|
|
this.region = region || guessedRegion || "us-east-1"
|
|
|
|
/** @type {Map<string, ArrayBuffer>} */
|
|
this.cache = cache || new Map()
|
|
this.datetime = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, "")
|
|
this.signQuery = signQuery
|
|
this.appendSessionToken = appendSessionToken || this.service === "iotdevicegateway"
|
|
|
|
this.headers.delete("Host") // Can't be set in insecure env anyway
|
|
|
|
if (this.service === "s3" && !this.signQuery && !this.headers.has("X-Amz-Content-Sha256")) {
|
|
this.headers.set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD")
|
|
}
|
|
|
|
const params = this.signQuery ? this.url.searchParams : this.headers
|
|
|
|
params.set("X-Amz-Date", this.datetime)
|
|
if (this.sessionToken && !this.appendSessionToken) {
|
|
params.set("X-Amz-Security-Token", this.sessionToken)
|
|
}
|
|
|
|
// headers are always lowercase in keys()
|
|
this.signableHeaders = ["host", ...this.headers.keys()]
|
|
.filter((header) => allHeaders || !UNSIGNABLE_HEADERS.has(header))
|
|
.sort()
|
|
|
|
this.signedHeaders = this.signableHeaders.join(";")
|
|
|
|
// headers are always trimmed:
|
|
// https://fetch.spec.whatwg.org/#concept-header-value-normalize
|
|
this.canonicalHeaders = this.signableHeaders
|
|
.map(
|
|
(header) =>
|
|
header + ":" + (header === "host" ? this.url.host : (this.headers.get(header) || "").replace(/\s+/g, " ")),
|
|
)
|
|
.join("\n")
|
|
|
|
this.credentialString = [this.datetime.slice(0, 8), this.region, this.service, "aws4_request"].join("/")
|
|
|
|
if (this.signQuery) {
|
|
if (this.service === "s3" && !params.has("X-Amz-Expires")) {
|
|
params.set("X-Amz-Expires", "86400") // 24 hours
|
|
}
|
|
params.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
|
params.set("X-Amz-Credential", this.accessKeyId + "/" + this.credentialString)
|
|
params.set("X-Amz-SignedHeaders", this.signedHeaders)
|
|
}
|
|
|
|
const apiPrefix = (process.env.NEXT_PUBLIC_API_PREFIX || "").replace(/\/$/, "")
|
|
let signingPathname = this.url.pathname
|
|
if (apiPrefix && signingPathname.startsWith(apiPrefix)) {
|
|
signingPathname = signingPathname.slice(apiPrefix.length) || "/"
|
|
}
|
|
|
|
if (this.service === "s3") {
|
|
try {
|
|
this.encodedPath = decodeURIComponent(signingPathname.replace(/\+/g, " "))
|
|
} catch {
|
|
this.encodedPath = signingPathname
|
|
}
|
|
} else {
|
|
this.encodedPath = signingPathname.replace(/\/+/g, "/")
|
|
}
|
|
if (!singleEncode) {
|
|
this.encodedPath = encodeURIComponent(this.encodedPath).replace(/%2F/g, "/")
|
|
}
|
|
this.encodedPath = encodeRfc3986(this.encodedPath)
|
|
|
|
const seenKeys = new Set()
|
|
this.encodedSearch = [...this.url.searchParams]
|
|
.filter(([k]) => {
|
|
if (!k) return false // no empty keys
|
|
if (this.service === "s3") {
|
|
if (seenKeys.has(k)) return false // first val only for S3
|
|
seenKeys.add(k)
|
|
}
|
|
return true
|
|
})
|
|
.map((pair) => pair.map((p) => encodeRfc3986(encodeURIComponent(p))))
|
|
.sort((a, b) => {
|
|
const [k1 = "", v1 = ""] = a ?? []
|
|
const [k2 = "", v2 = ""] = b ?? []
|
|
if (k1 < k2) return -1
|
|
if (k1 > k2) return 1
|
|
if (v1 < v2) return -1
|
|
if (v1 > v2) return 1
|
|
return 0
|
|
})
|
|
.map((pair) => pair.join("="))
|
|
.join("&")
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<{
|
|
* method: string
|
|
* url: URL
|
|
* headers: Headers
|
|
* body?: BodyInit | null
|
|
* }>}
|
|
*/
|
|
async sign() {
|
|
if (this.signQuery) {
|
|
this.url.searchParams.set("X-Amz-Signature", await this.signature())
|
|
if (this.sessionToken && this.appendSessionToken) {
|
|
this.url.searchParams.set("X-Amz-Security-Token", this.sessionToken)
|
|
}
|
|
} else {
|
|
this.headers.set("Authorization", await this.authHeader())
|
|
}
|
|
|
|
return {
|
|
method: this.method,
|
|
url: this.url,
|
|
headers: this.headers,
|
|
body: this.body,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async authHeader() {
|
|
return [
|
|
"AWS4-HMAC-SHA256 Credential=" + this.accessKeyId + "/" + this.credentialString,
|
|
"SignedHeaders=" + this.signedHeaders,
|
|
"Signature=" + (await this.signature()),
|
|
].join(", ")
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async signature() {
|
|
const date = this.datetime.slice(0, 8)
|
|
const cacheKey = [this.secretAccessKey, date, this.region, this.service].join()
|
|
let kCredentials = this.cache.get(cacheKey)
|
|
if (!kCredentials) {
|
|
const kDate = await hmac("AWS4" + this.secretAccessKey, date)
|
|
const kRegion = await hmac(kDate, this.region)
|
|
const kService = await hmac(kRegion, this.service)
|
|
kCredentials = await hmac(kService, "aws4_request")
|
|
this.cache.set(cacheKey, kCredentials)
|
|
}
|
|
return buf2hex(await hmac(kCredentials, await this.stringToSign()))
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async stringToSign() {
|
|
return [
|
|
"AWS4-HMAC-SHA256",
|
|
this.datetime,
|
|
this.credentialString,
|
|
buf2hex(await hash(await this.canonicalString())),
|
|
].join("\n")
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async canonicalString() {
|
|
return [
|
|
this.method.toUpperCase(),
|
|
this.encodedPath,
|
|
this.encodedSearch,
|
|
this.canonicalHeaders + "\n",
|
|
this.signedHeaders,
|
|
await this.hexBodyHash(),
|
|
].join("\n")
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async hexBodyHash() {
|
|
let hashHeader =
|
|
this.headers.get("X-Amz-Content-Sha256") || (this.service === "s3" && this.signQuery ? "UNSIGNED-PAYLOAD" : null)
|
|
if (hashHeader == null) {
|
|
if (this.body && typeof this.body !== "string" && !("byteLength" in this.body)) {
|
|
throw new Error(
|
|
"body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header",
|
|
)
|
|
}
|
|
hashHeader = buf2hex(await hash(this.body || ""))
|
|
}
|
|
return hashHeader
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string | BufferSource} key
|
|
* @param {string} string
|
|
* @returns {Promise<ArrayBuffer>}
|
|
*/
|
|
async function hmac(
|
|
key: string | ArrayBuffer | ArrayBufferView | forge.util.ByteStringBuffer | null,
|
|
string: string | undefined,
|
|
) {
|
|
const hmac = forge.hmac.create()
|
|
|
|
// Handle key conversion
|
|
if (typeof key === "string") {
|
|
hmac.start("sha256", key)
|
|
} else if (key instanceof ArrayBuffer) {
|
|
const keyArray = new Uint8Array(key)
|
|
const keyString = String.fromCharCode.apply(null, [...keyArray])
|
|
hmac.start("sha256", keyString)
|
|
} else if (key && "getBytes" in key) {
|
|
// Forge ByteStringBuffer object
|
|
hmac.start("sha256", key.getBytes())
|
|
} else if (key) {
|
|
// ArrayBufferView
|
|
const keyArray = new Uint8Array(key.buffer)
|
|
const keyString = String.fromCharCode.apply(null, [...keyArray])
|
|
hmac.start("sha256", keyString)
|
|
} else {
|
|
// Handle null
|
|
hmac.start("sha256", "")
|
|
}
|
|
|
|
// Handle input string
|
|
if (typeof string === "string") {
|
|
hmac.update(string)
|
|
} else {
|
|
const encoded = encoder.encode(string)
|
|
const stringValue = String.fromCharCode.apply(null, [...encoded])
|
|
hmac.update(stringValue)
|
|
}
|
|
|
|
// Convert forge output to ArrayBuffer
|
|
const digestBytes = hmac.digest().getBytes()
|
|
const buffer = new Uint8Array(digestBytes.length)
|
|
for (let i = 0; i < digestBytes.length; i++) {
|
|
buffer[i] = digestBytes.charCodeAt(i)
|
|
}
|
|
|
|
return buffer.buffer
|
|
}
|
|
|
|
/**
|
|
* @param {string | BufferSource} content
|
|
* @returns {Promise<ArrayBuffer>}
|
|
*/
|
|
async function hash(content: string | ArrayBufferView<ArrayBufferLike> | ArrayBuffer) {
|
|
const md = forge.md.sha256.create()
|
|
|
|
if (typeof content === "string") {
|
|
md.update(content)
|
|
} else {
|
|
// Convert BufferSource to forge format
|
|
const contentArray = content instanceof ArrayBuffer ? new Uint8Array(content) : new Uint8Array(content.buffer)
|
|
const contentString = String.fromCharCode.apply(null, [...contentArray])
|
|
md.update(contentString)
|
|
}
|
|
|
|
// Convert forge output to ArrayBuffer
|
|
const digestBytes = md.digest().getBytes()
|
|
const buffer = new Uint8Array(digestBytes.length)
|
|
for (let i = 0; i < digestBytes.length; i++) {
|
|
buffer[i] = digestBytes.charCodeAt(i)
|
|
}
|
|
|
|
return buffer.buffer
|
|
}
|
|
|
|
const HEX_CHARS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]
|
|
|
|
/**
|
|
* @param {ArrayBufferLike} arrayBuffer
|
|
* @returns {string}
|
|
*/
|
|
function buf2hex(arrayBuffer: ArrayBuffer) {
|
|
const buffer = new Uint8Array(arrayBuffer)
|
|
let out = ""
|
|
for (let idx = 0; idx < buffer.length; idx++) {
|
|
const n = buffer[idx]
|
|
if (n === undefined) {
|
|
continue
|
|
}
|
|
out += HEX_CHARS[(n >>> 4) & 0xf]
|
|
out += HEX_CHARS[n & 0xf]
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* @param {string} urlEncodedStr
|
|
* @returns {string}
|
|
*/
|
|
function encodeRfc3986(urlEncodedStr: string) {
|
|
return urlEncodedStr.replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase())
|
|
}
|
|
|
|
/**
|
|
* @param {URL} url
|
|
* @param {Headers} headers
|
|
* @returns {[string, string]} [service, region]
|
|
*/
|
|
function guessServiceRegion(url: URL, headers: Headers) {
|
|
const { hostname, pathname } = url
|
|
|
|
if (hostname.endsWith(".on.aws")) {
|
|
const match = hostname.match(/^[^.]{1,63}\.lambda-url\.([^.]{1,63})\.on\.aws$/)
|
|
return match != null ? ["lambda", match[1] || ""] : ["", ""]
|
|
}
|
|
if (hostname.endsWith(".r2.cloudflarestorage.com")) {
|
|
return ["s3", "auto"]
|
|
}
|
|
if (hostname.endsWith(".backblazeb2.com")) {
|
|
const match = hostname.match(/^(?:[^.]{1,63}\.)?s3\.([^.]{1,63})\.backblazeb2\.com$/)
|
|
return match != null ? ["s3", match[1] || ""] : ["", ""]
|
|
}
|
|
const match = hostname.replace("dualstack.", "").match(/([^.]{1,63})\.(?:([^.]{0,63})\.)?amazonaws\.com(?:\.cn)?$/)
|
|
let service = (match && match[1]) || ""
|
|
let region = match && match[2]
|
|
|
|
if (region === "us-gov") {
|
|
region = "us-gov-west-1"
|
|
} else if (region === "s3" || region === "s3-accelerate") {
|
|
region = "us-east-1"
|
|
service = "s3"
|
|
} else if (service === "iot") {
|
|
if (hostname.startsWith("iot.")) {
|
|
service = "execute-api"
|
|
} else if (hostname.startsWith("data.jobs.iot.")) {
|
|
service = "iot-jobs-data"
|
|
} else {
|
|
service = pathname === "/mqtt" ? "iotdevicegateway" : "iotdata"
|
|
}
|
|
} else if (service === "autoscaling") {
|
|
const targetPrefix = (headers.get("X-Amz-Target") || "").split(".")[0]
|
|
if (targetPrefix === "AnyScaleFrontendService") {
|
|
service = "application-autoscaling"
|
|
} else if (targetPrefix === "AnyScaleScalingPlannerFrontendService") {
|
|
service = "autoscaling-plans"
|
|
}
|
|
} else if (region == null && service.startsWith("s3-")) {
|
|
region = service.slice(3).replace(/^fips-|^external-1/, "")
|
|
service = "s3"
|
|
} else if (service.endsWith("-fips")) {
|
|
service = service.slice(0, -5)
|
|
} else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) {
|
|
;[service, region] = [region, service]
|
|
}
|
|
|
|
return [HOST_SERVICES[service] || service, region || ""]
|
|
}
|