diff --git a/src/utils/s3/auth.ts b/src/utils/s3/auth.ts new file mode 100644 index 0000000..950625d --- /dev/null +++ b/src/utils/s3/auth.ts @@ -0,0 +1,274 @@ +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 '/'; + return 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' }; + } + + 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}/${parsed.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, + 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); + + const canonicalHeaders = signedHeaders + .split(';') + .map((h) => `${h}:host\n`) + .join(''); + const signedHeadersStr = signedHeaders; + const hashedPayload = 'UNSIGNED-PAYLOAD'; + + const canonicalRequest = `${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeadersStr}\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'); +}; diff --git a/src/utils/s3/xml.ts b/src/utils/s3/xml.ts new file mode 100644 index 0000000..d52c19c --- /dev/null +++ b/src/utils/s3/xml.ts @@ -0,0 +1,234 @@ +const escapeXml = (str: string): string => + str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const isoDate = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z'); + +// ─────── Bucket operations ─────── + +export const listBucketsXml = ( + buckets: { name: string; createdAt: Date }[], + requestId: string, +): string => ` + + + ${buckets.map((b) => ` + ${escapeXml(b.name)} + ${isoDate(b.createdAt)} + `).join('')} + +`; + +// ─────── Object listing ─────── + +export const listBucketResultXml = ( + bucketName: string, + objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[], + prefixes: string[], + isTruncated: boolean, + marker: string | null, + maxKeys: number, + prefix: string, + delimiter: string | null, + nextMarker: string | null, + requestId: string, +): string => ` + + ${escapeXml(bucketName)} + ${escapeXml(prefix)} + ${escapeXml(marker || '')} + ${maxKeys} + ${escapeXml(delimiter || '')} + ${isTruncated} + ${objects.map((o) => ` + ${escapeXml(o.key)} + ${isoDate(o.lastModified)} + "${o.etag}" + ${o.sizeBytes} + STANDARD + `).join('')} + ${prefixes.map((p) => ` + ${escapeXml(p)} + `).join('')} + ${nextMarker ? `${escapeXml(nextMarker)}` : ''} +`; + +export const listBucketV2ResultXml = ( + bucketName: string, + objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[], + prefixes: string[], + isTruncated: boolean, + maxKeys: number, + prefix: string, + delimiter: string | null, + continuationToken: string | null, + nextContinuationToken: string | null, + keyCount: number, + requestId: string, +): string => ` + + ${escapeXml(bucketName)} + ${escapeXml(prefix)} + ${maxKeys} + ${keyCount} + ${delimiter ? `${escapeXml(delimiter)}` : ''} + ${continuationToken ? `${escapeXml(continuationToken)}` : ''} + ${isTruncated} + ${objects.map((o) => ` + ${escapeXml(o.key)} + ${isoDate(o.lastModified)} + "${o.etag}" + ${o.sizeBytes} + STANDARD + `).join('')} + ${prefixes.map((p) => ` + ${escapeXml(p)} + `).join('')} + ${nextContinuationToken ? `${escapeXml(nextContinuationToken)}` : ''} +`; + +// ─────── Multipart ─────── + +export const initiateMultipartUploadXml = ( + bucketName: string, + key: string, + uploadId: string, +): string => ` + + ${escapeXml(bucketName)} + ${escapeXml(key)} + ${uploadId} +`; + +export const listPartsXml = ( + bucketName: string, + key: string, + uploadId: string, + parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[], + maxParts: number, + isTruncated: boolean, + requestId: string, +): string => ` + + ${escapeXml(bucketName)} + ${escapeXml(key)} + ${uploadId} + ${maxParts} + ${isTruncated} + ${parts.map((p) => ` + ${p.partNumber} + ${isoDate(p.createdAt)} + "${p.etag}" + ${p.sizeBytes} + `).join('')} +`; + +export const completeMultipartUploadXml = ( + bucketName: string, + key: string, + etag: string, + location: string, +): string => ` + + ${escapeXml(location)} + ${escapeXml(bucketName)} + ${escapeXml(key)} + "${etag}" +`; + +// ─────── Delete result ─────── + +export const deleteResultXml = ( + deleted: string[], + errors: { key: string; code: string; message: string }[], +): string => ` + + ${deleted.map((key) => ` + ${escapeXml(key)} + `).join('')} + ${errors.map((e) => ` + ${escapeXml(e.key)} + ${e.code} + ${escapeXml(e.message)} + `).join('')} +`; + +// ─────── Copy ─────── + +export const copyObjectResultXml = ( + etag: string, + lastModified: Date, +): string => ` + + "${etag}" + ${isoDate(lastModified)} +`; + +// ─────── Error ─────── + +export const s3ErrorXml = ( + code: string, + message: string, + resource: string, + requestId: string, +): string => ` + + ${code} + ${escapeXml(message)} + ${escapeXml(resource)} + ${requestId} +`; + +export const s3ErrorResponse = ( + code: string, + message: string, + resource: string, + status: number, +): Response => + new Response(s3ErrorXml(code, message, resource, ''), { + status, + headers: { 'content-type': 'application/xml' }, + }); + +// ─────── DeleteObjects XML parser ─────── + +export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => { + const keys: string[] = []; + const keyRegex = /([^<]+)<\/Key>/g; + let match; + while ((match = keyRegex.exec(body)) !== null) { + keys.push(match[1]); + } + const quiet = body.includes('true') || body.includes('true '); + return { keys, quiet }; +}; + +// ─────── CompleteMultipartUpload XML parser ─────── + +export interface CompletePart { + partNumber: number; + etag: string; +} + +export const parseCompleteMultipartBody = (body: string): CompletePart[] => { + const parts: CompletePart[] = []; + const partRegex = /[\s\S]*?<\/Part>/g; + const partMatch = body.match(partRegex) || []; + + for (const partXml of partMatch) { + const numMatch = partXml.match(/(\d+)<\/PartNumber>/); + const etagMatch = partXml.match(/"?([^"<\s]+)"?<\/ETag>/); + if (numMatch && etagMatch) { + parts.push({ + partNumber: parseInt(numMatch[1], 10), + etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''), + }); + } + } + + return parts; +};