feat(common): enhance HAWK authentication with payload hash calculation (#5067)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Anwarul Islam 2025-05-20 17:31:04 +06:00 committed by GitHub
parent 2232f0291c
commit 3980847983
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 112 additions and 23 deletions

View file

@ -121,15 +121,14 @@
</div>
</div>
<!-- TODO: implement include payload hash -->
<!-- <div class="px-4 my-6">
<div class="px-4 my-6">
<HoppSmartCheckbox
:on="auth.includePayloadHash"
@change="auth.includePayloadHash = !auth.includePayloadHash"
>
Include Payload Hash
</HoppSmartCheckbox>
</div> -->
</div>
</template>
<script setup lang="ts">

View file

@ -248,7 +248,14 @@ export const getComputedAuthHeaders = async (
})
}
} else if (request.auth.authType === "hawk") {
const { method, endpoint } = req as HoppRESTRequest
const { method, endpoint, body } = req as HoppRESTRequest
// Get the body content for payload hash calculation
const payload = getFinalBodyFromRequest(
req as HoppRESTRequest,
envVars,
showKeyIfSecret
)
const hawkHeader = await calculateHawkHeader({
url: parseTemplateString(endpoint, envVars), // URL
@ -257,6 +264,10 @@ export const getComputedAuthHeaders = async (
key: parseTemplateString(request.auth.authKey, envVars),
algorithm: request.auth.algorithm,
// Add content type and payload
contentType: body.contentType,
payload,
// advanced parameters (optional)
includePayloadHash: request.auth.includePayloadHash,
nonce: request.auth.nonce
@ -608,7 +619,8 @@ function getFinalBodyFromRequest(
// we split array blobs into separate entries (FormData will then join them together during exec)
arrayFlatMap((x) =>
x.isFile
? x.value.map((v) => ({
? // @ts-expect-error TODO: Fix this type error
x.value.map((v) => ({
key: parseTemplateString(x.key, envVariables),
value: v as string | Blob,
contentType: x.contentType,

View file

@ -1,11 +1,11 @@
import { HoppRESTRequest } from "../rest"
interface HawkOptions {
id: string
key: string
algorithm: "sha256" | "sha1"
method: string
url: string
includePayloadHash: boolean
payload?: string | FormData | File | null
// Optional parameters
user?: string
@ -14,6 +14,11 @@ interface HawkOptions {
app?: string
dlg?: string
timestamp?: number
// Payload options
includePayloadHash: boolean
payload?: string | FormData | File | null | Blob
contentType?: HoppRESTRequest["body"]["contentType"]
}
async function generateNonce(length: number = 6): Promise<string> {
@ -31,6 +36,19 @@ function sha256Hash(data: string): Promise<ArrayBuffer> {
return crypto.subtle.digest("SHA-256", dataBuffer)
}
function sha1Hash(data: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder()
const dataBuffer = encoder.encode(data)
return crypto.subtle.digest("SHA-1", dataBuffer)
}
async function hashData(
data: string,
algorithm: "sha256" | "sha1"
): Promise<ArrayBuffer> {
return algorithm === "sha256" ? sha256Hash(data) : sha1Hash(data)
}
async function hmacSign(
key: string,
message: string,
@ -59,24 +77,74 @@ async function hmacSign(
return btoa(String.fromCharCode.apply(null, [...new Uint8Array(signature)]))
}
/**
* Normalize line endings to '\n' to ensure consistent hash generation
*/
function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}
/**
* Get the content of a payload for hash calculation
* This function needs to exactly match the server's payload hash calculation
*/
async function getPayloadContent(
payload: string | FormData | File | null
payload: string | FormData | File | Blob | null,
contentType: string
): Promise<string> {
if (!payload) return ""
// For form data
if (payload instanceof FormData) {
const pairs: string[] = []
payload.forEach((value, key) => {
pairs.push(`${key}=${value}`)
})
return pairs.join("&")
if (contentType === "multipart/form-data") {
// For multipart form data, we need to extract the parts
const parts: string[] = []
payload.forEach((value, key) => {
if (typeof value === "string") {
parts.push(`${key}=${value}`)
} else {
// For file parts, use the file name
parts.push(`${key}=${value instanceof File ? value.name : "blob"}`)
}
})
return normalizeLineEndings(parts.join("&"))
} else {
// For url-encoded form data
const pairs: string[] = []
payload.forEach((value, key) => {
if (typeof value === "string") {
pairs.push(`${key}=${encodeURIComponent(value)}`)
}
})
return normalizeLineEndings(pairs.join("&"))
}
}
if (payload instanceof File) {
return await payload.text()
// For blob/file types
if (payload instanceof Blob) {
try {
const text = await payload.text()
return normalizeLineEndings(text)
} catch (e) {
console.error("Failed to read blob content", e)
return ""
}
}
return payload.toString()
// Handle JSON specifically
if (contentType.includes("application/json") && typeof payload === "string") {
try {
// Parse and re-stringify to ensure consistent formatting
const jsonObj = JSON.parse(payload)
return normalizeLineEndings(JSON.stringify(jsonObj))
} catch (e) {
// If not valid JSON, use as-is
return normalizeLineEndings(payload.toString())
}
}
// Default: convert to string and normalize line endings
return normalizeLineEndings(payload.toString())
}
export async function calculateHawkHeader(
@ -113,13 +181,23 @@ export async function calculateHawkHeader(
// Calculate payload hash if needed
if (options.includePayloadHash && options.payload) {
const content = await getPayloadContent(options.payload)
const contentType = "text/plain"
const hashBase = `hawk.1.payload\n${contentType}\n${content}\n`
const contentHash = await sha256Hash(hashBase)
artifacts.hash = btoa(
String.fromCharCode.apply(null, [...new Uint8Array(contentHash)])
)
try {
const contentType = options.contentType || "text/plain"
const content = await getPayloadContent(options.payload, contentType)
// Create the normalized payload string as per HAWK spec
const normalizedPayload = `hawk.1.payload\n${contentType}\n${content}\n`
// Hash the normalized payload
const contentHash = await hashData(normalizedPayload, options.algorithm)
// Convert hash to base64
artifacts.hash = btoa(
String.fromCharCode.apply(null, [...new Uint8Array(contentHash)])
)
} catch (error) {
console.error("Error calculating payload hash:", error)
}
}
// Construct the string to sign according to Hawk spec