export interface SigV4Result { isValid: boolean; credential: { accessKey: string; date: string; region: string; service: string; } | null; errorCode?: string; } const SERVICE = 's3'; const TERMINATION = 'aws4_request'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const buf = (data: string | ArrayBuffer | Uint8Array): Uint8Array => { if (data instanceof Uint8Array) return data; if (data instanceof ArrayBuffer) return new Uint8Array(data); return new TextEncoder().encode(data); }; const sha256Hex = async (data: string | Uint8Array | ArrayBuffer): Promise => { const hashBuffer = await crypto.subtle.digest('SHA-256', buf(data) as never); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); }; const hmacSha256 = async (key: Uint8Array, message: string): Promise => { const cryptoKey = await crypto.subtle.importKey( 'raw', key as never, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const result = await crypto.subtle.sign('HMAC', cryptoKey, buf(message) as never); return new Uint8Array(result); }; const getSigningKey = async ( secretKey: string, dateStamp: string, region: string, ): Promise => { let key = await hmacSha256(buf(`AWS4${secretKey}`), dateStamp); key = await hmacSha256(key, region); key = await hmacSha256(key, SERVICE); return await hmacSha256(key, TERMINATION); }; const hmacHex = async (key: Uint8Array, message: string): Promise => { const result = await hmacSha256(key, message); return Array.from(result) .map((b) => b.toString(16).padStart(2, '0')) .join(''); }; const parseAuthorizationHeader = (authHeader: string) => { const credentialMatch = authHeader.match(/Credential=([^,]+)/); const signedHeadersMatch = authHeader.match(/SignedHeaders=([^,]+)/); const signatureMatch = authHeader.match(/Signature=([^,]+)/); if (!credentialMatch || !signedHeadersMatch || !signatureMatch) return null; const credentialParts = credentialMatch[1].split('/'); if (credentialParts.length !== 5) return null; return { accessKey: credentialParts[0], date: credentialParts[1], region: credentialParts[2], service: credentialParts[3], termination: credentialParts[4], signedHeaders: signedHeadersMatch[1], signature: signatureMatch[1], }; }; const buildCanonicalRequest = ( method: string, canonicalUri: string, canonicalQueryString: string, signedHeaders: string, headers: Record, hashedPayload: string, ): string => { const canonicalHeaders = signedHeaders .split(';') .map((h) => { const value = headers[h.toLowerCase()] || ''; return `${h.toLowerCase()}:${value.trim()}\n`; }) .join(''); return `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`; }; const normalizeUri = (uri: string): string => { if (!uri || uri === '') return '/'; // AWS SigV4 requires URI-decoded paths in the canonical request return decodeURIComponent(uri); }; const buildCanonicalQueryString = (searchParams: URLSearchParams): string => { const params: string[] = []; const keys = Array.from(searchParams.keys()).sort(); for (const key of keys) { const values = searchParams.getAll(key).sort(); for (const value of values) { params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); } } return params.join('&'); }; const getHashedPayload = async ( body: string | null, contentSha256: string | null, ): Promise => { if (contentSha256) return contentSha256; if (!body || body.length === 0) return await sha256Hex(''); return await sha256Hex(body); }; export const verifySignature = async ( method: string, url: string, headers: Record, body: string | null, s3AccessKey: string, s3SecretKey: string, region: string, ): Promise => { const authHeader = headers['authorization']; if (!authHeader || !authHeader.startsWith('AWS4-HMAC-SHA256')) { return { isValid: false, credential: null, errorCode: 'AccessDenied' }; } const parsed = parseAuthorizationHeader(authHeader); if (!parsed) { return { isValid: false, credential: null, errorCode: 'AccessDenied' }; } if (parsed.accessKey !== s3AccessKey) { return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; } if (parsed.region !== region) { return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; } const parsedUrl = new URL(url, 'http://localhost'); const canonicalUri = normalizeUri(parsedUrl.pathname); const canonicalQueryString = buildCanonicalQueryString(parsedUrl.searchParams); const contentSha256 = headers['x-amz-content-sha256'] || null; const hashedPayload = await getHashedPayload(body, contentSha256); const canonicalRequest = buildCanonicalRequest( method, canonicalUri, canonicalQueryString, parsed.signedHeaders, headers, hashedPayload, ); const hashedCanonicalRequest = await sha256Hex(canonicalRequest); const amzDate = headers['x-amz-date'] || ''; const dateStamp = parsed.date; const credentialScope = `${dateStamp}/${region}/${parsed.service}/${parsed.termination}`; const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`; const signingKey = await getSigningKey(s3SecretKey, dateStamp, region); const expectedSignature = await hmacHex(signingKey, stringToSign); if (expectedSignature !== parsed.signature) { return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; } return { isValid: true, credential: { accessKey: parsed.accessKey, date: parsed.date, region: parsed.region, service: parsed.service, }, }; }; export const verifyPresignedUrl = async ( url: string, method: string, s3AccessKey: string, s3SecretKey: string, region: string, ): Promise => { const parsedUrl = new URL(url); const queryParams = Object.fromEntries(parsedUrl.searchParams.entries()); const algorithm = queryParams['X-Amz-Algorithm']; const credential = queryParams['X-Amz-Credential']; const signedHeaders = queryParams['X-Amz-SignedHeaders']; const signature = queryParams['X-Amz-Signature']; const expires = parseInt(queryParams['X-Amz-Expires'] || '0', 10); const amzDate = queryParams['X-Amz-Date']; if ( !algorithm || algorithm !== 'AWS4-HMAC-SHA256' || !credential || !signature || !expires || !amzDate ) { return { isValid: false, credential: null, errorCode: 'AccessDenied' }; } // Check expiration const dateObj = new Date( parseInt(amzDate.substring(0, 4), 10), parseInt(amzDate.substring(4, 6), 10) - 1, parseInt(amzDate.substring(6, 8), 10), parseInt(amzDate.substring(9, 11), 10), parseInt(amzDate.substring(11, 13), 10), parseInt(amzDate.substring(13, 15), 10), ); const expiresMs = expires * 1000; if (Date.now() > dateObj.getTime() + expiresMs) { return { isValid: false, credential: null, errorCode: 'AccessDenied' }; } const credParts = credential.split('/'); const presignedAccessKey = credParts[0]; if (presignedAccessKey !== s3AccessKey) { return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; } const dateStamp = credParts[1] || amzDate.substring(0, 8); const canonicalUri = normalizeUri(parsedUrl.pathname); const sortedParams = new URLSearchParams(); const paramKeys = Object.keys(queryParams).sort(); for (const key of paramKeys) { if (key !== 'X-Amz-Signature') { sortedParams.append(key, queryParams[key]); } } const canonicalQueryString = buildCanonicalQueryString(sortedParams); // Build canonical headers for presigned URL — only 'host' is typically signed const signedHeaderList = signedHeaders.split(';').filter(Boolean); const canonicalHeaders = signedHeaderList .map((h) => `${h.toLowerCase()}:${h === 'host' ? parsedUrl.host : ''}\n`) .join(''); const hashedPayload = 'UNSIGNED-PAYLOAD'; const canonicalRequest = `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`; const hashedCanonicalRequest = await sha256Hex(canonicalRequest); const credentialScope = `${dateStamp}/${region}/s3/aws4_request`; const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`; const signingKey = await getSigningKey(s3SecretKey, dateStamp, region); const expectedSignature = await hmacHex(signingKey, stringToSign); if (expectedSignature !== signature) { return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; } return { isValid: true, credential: null }; }; export const isS3Request = (headers: Record): boolean => { const auth = headers['authorization'] || ''; return auth.startsWith('AWS4-HMAC-SHA256'); };