feat: add S3 SigV4 auth verification and XML builders
This commit is contained in:
@@ -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<string> => {
|
||||
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<Uint8Array> => {
|
||||
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<Uint8Array> => {
|
||||
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<string> => {
|
||||
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<string, string>,
|
||||
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<string> => {
|
||||
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<string, string>,
|
||||
body: string | null,
|
||||
s3AccessKey: string,
|
||||
s3SecretKey: string,
|
||||
region: string,
|
||||
): Promise<SigV4Result> => {
|
||||
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<SigV4Result> => {
|
||||
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<string, string>): boolean => {
|
||||
const auth = headers['authorization'] || '';
|
||||
return auth.startsWith('AWS4-HMAC-SHA256');
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
const escapeXml = (str: string): string =>
|
||||
str
|
||||
.replace(/&/g, '&')
|
||||
.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 => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Buckets>
|
||||
${buckets.map((b) => `<Bucket>
|
||||
<Name>${escapeXml(b.name)}</Name>
|
||||
<CreationDate>${isoDate(b.createdAt)}</CreationDate>
|
||||
</Bucket>`).join('')}
|
||||
</Buckets>
|
||||
</ListAllMyBucketsResult>`;
|
||||
|
||||
// ─────── 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 => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>${escapeXml(bucketName)}</Name>
|
||||
<Prefix>${escapeXml(prefix)}</Prefix>
|
||||
<Marker>${escapeXml(marker || '')}</Marker>
|
||||
<MaxKeys>${maxKeys}</MaxKeys>
|
||||
<Delimiter>${escapeXml(delimiter || '')}</Delimiter>
|
||||
<IsTruncated>${isTruncated}</IsTruncated>
|
||||
${objects.map((o) => `<Contents>
|
||||
<Key>${escapeXml(o.key)}</Key>
|
||||
<LastModified>${isoDate(o.lastModified)}</LastModified>
|
||||
<ETag>"${o.etag}"</ETag>
|
||||
<Size>${o.sizeBytes}</Size>
|
||||
<StorageClass>STANDARD</StorageClass>
|
||||
</Contents>`).join('')}
|
||||
${prefixes.map((p) => `<CommonPrefixes>
|
||||
<Prefix>${escapeXml(p)}</Prefix>
|
||||
</CommonPrefixes>`).join('')}
|
||||
${nextMarker ? `<NextMarker>${escapeXml(nextMarker)}</NextMarker>` : ''}
|
||||
</ListBucketResult>`;
|
||||
|
||||
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 => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResultV2 xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>${escapeXml(bucketName)}</Name>
|
||||
<Prefix>${escapeXml(prefix)}</Prefix>
|
||||
<MaxKeys>${maxKeys}</MaxKeys>
|
||||
<KeyCount>${keyCount}</KeyCount>
|
||||
${delimiter ? `<Delimiter>${escapeXml(delimiter)}</Delimiter>` : ''}
|
||||
${continuationToken ? `<ContinuationToken>${escapeXml(continuationToken)}</ContinuationToken>` : ''}
|
||||
<IsTruncated>${isTruncated}</IsTruncated>
|
||||
${objects.map((o) => `<Contents>
|
||||
<Key>${escapeXml(o.key)}</Key>
|
||||
<LastModified>${isoDate(o.lastModified)}</LastModified>
|
||||
<ETag>"${o.etag}"</ETag>
|
||||
<Size>${o.sizeBytes}</Size>
|
||||
<StorageClass>STANDARD</StorageClass>
|
||||
</Contents>`).join('')}
|
||||
${prefixes.map((p) => `<CommonPrefixes>
|
||||
<Prefix>${escapeXml(p)}</Prefix>
|
||||
</CommonPrefixes>`).join('')}
|
||||
${nextContinuationToken ? `<NextContinuationToken>${escapeXml(nextContinuationToken)}</NextContinuationToken>` : ''}
|
||||
</ListBucketResultV2>`;
|
||||
|
||||
// ─────── Multipart ───────
|
||||
|
||||
export const initiateMultipartUploadXml = (
|
||||
bucketName: string,
|
||||
key: string,
|
||||
uploadId: string,
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||
<Key>${escapeXml(key)}</Key>
|
||||
<UploadId>${uploadId}</UploadId>
|
||||
</InitiateMultipartUploadResult>`;
|
||||
|
||||
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 => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||
<Key>${escapeXml(key)}</Key>
|
||||
<UploadId>${uploadId}</UploadId>
|
||||
<MaxParts>${maxParts}</MaxParts>
|
||||
<IsTruncated>${isTruncated}</IsTruncated>
|
||||
${parts.map((p) => `<Part>
|
||||
<PartNumber>${p.partNumber}</PartNumber>
|
||||
<LastModified>${isoDate(p.createdAt)}</LastModified>
|
||||
<ETag>"${p.etag}"</ETag>
|
||||
<Size>${p.sizeBytes}</Size>
|
||||
</Part>`).join('')}
|
||||
</ListPartsResult>`;
|
||||
|
||||
export const completeMultipartUploadXml = (
|
||||
bucketName: string,
|
||||
key: string,
|
||||
etag: string,
|
||||
location: string,
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Location>${escapeXml(location)}</Location>
|
||||
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||
<Key>${escapeXml(key)}</Key>
|
||||
<ETag>"${etag}"</ETag>
|
||||
</CompleteMultipartUploadResult>`;
|
||||
|
||||
// ─────── Delete result ───────
|
||||
|
||||
export const deleteResultXml = (
|
||||
deleted: string[],
|
||||
errors: { key: string; code: string; message: string }[],
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
${deleted.map((key) => `<Deleted>
|
||||
<Key>${escapeXml(key)}</Key>
|
||||
</Deleted>`).join('')}
|
||||
${errors.map((e) => `<Error>
|
||||
<Key>${escapeXml(e.key)}</Key>
|
||||
<Code>${e.code}</Code>
|
||||
<Message>${escapeXml(e.message)}</Message>
|
||||
</Error>`).join('')}
|
||||
</DeleteResult>`;
|
||||
|
||||
// ─────── Copy ───────
|
||||
|
||||
export const copyObjectResultXml = (
|
||||
etag: string,
|
||||
lastModified: Date,
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CopyObjectResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<ETag>"${etag}"</ETag>
|
||||
<LastModified>${isoDate(lastModified)}</LastModified>
|
||||
</CopyObjectResult>`;
|
||||
|
||||
// ─────── Error ───────
|
||||
|
||||
export const s3ErrorXml = (
|
||||
code: string,
|
||||
message: string,
|
||||
resource: string,
|
||||
requestId: string,
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>${code}</Code>
|
||||
<Message>${escapeXml(message)}</Message>
|
||||
<Resource>${escapeXml(resource)}</Resource>
|
||||
<RequestId>${requestId}</RequestId>
|
||||
</Error>`;
|
||||
|
||||
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>([^<]+)<\/Key>/g;
|
||||
let match;
|
||||
while ((match = keyRegex.exec(body)) !== null) {
|
||||
keys.push(match[1]);
|
||||
}
|
||||
const quiet = body.includes('<Quiet>true</Quiet>') || body.includes('<Quiet>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 = /<Part>[\s\S]*?<\/Part>/g;
|
||||
const partMatch = body.match(partRegex) || [];
|
||||
|
||||
for (const partXml of partMatch) {
|
||||
const numMatch = partXml.match(/<PartNumber>(\d+)<\/PartNumber>/);
|
||||
const etagMatch = partXml.match(/<ETag>"?([^"<\s]+)"?<\/ETag>/);
|
||||
if (numMatch && etagMatch) {
|
||||
parts.push({
|
||||
partNumber: parseInt(numMatch[1], 10),
|
||||
etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
Reference in New Issue
Block a user