// SigV4 on the receiving side. The gateway reads the access key out of every // S3 request to know whose s3d it is for. Keys it minted itself (grants to // other people) are fully verified here, because s3d never sees them; an // owner's own s3d keys are verified again by s3d, so for those the gateway // only needs the key id. import crypto from 'node:crypto'; import { uriEncode, canonicalQuery, signingKey, REGION, SERVICE } from '../core/s3.js'; const MAX_SKEW_MS = 15 * 60_000; // { akid, presigned, signedHeaders, signature, date, scope } or null. export function parseAuth(req, url) { const h = req.headers.authorization; if (h) { const m = /^AWS4-HMAC-SHA256\s+Credential=([^/,\s]+)\/(\d{8})\/([^/]+)\/([^/]+)\/aws4_request,\s*SignedHeaders=([^,\s]+),\s*Signature=([0-9a-f]{64})\s*$/i.exec(h); if (!m) return null; return { akid: m[1], day: m[2], region: m[3], service: m[4], signedHeaders: m[5].toLowerCase(), signature: m[6].toLowerCase(), date: req.headers['x-amz-date'], presigned: false }; } const q = url.searchParams; if (q.get('X-Amz-Algorithm') === 'AWS4-HMAC-SHA256') { const cred = /^([^/]+)\/(\d{8})\/([^/]+)\/([^/]+)\/aws4_request$/.exec(q.get('X-Amz-Credential') || ''); if (!cred) return null; return { akid: cred[1], day: cred[2], region: cred[3], service: cred[4], signedHeaders: (q.get('X-Amz-SignedHeaders') || '').toLowerCase(), signature: (q.get('X-Amz-Signature') || '').toLowerCase(), date: q.get('X-Amz-Date'), expires: Number(q.get('X-Amz-Expires') || 0), presigned: true, }; } return null; } function parseAmzDate(s) { const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(s || ''); return m ? Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6]) : NaN; } // The raw path as the client sent it, decoded and re-encoded the one way // SigV4 for S3 defines, so clients that over- or under-escape still match. export function canonicalPath(rawPath) { const decoded = rawPath.split('/').map((s) => { try { return decodeURIComponent(s); } catch { return s; } }); return decoded.map((s) => uriEncode(s)).join('/'); } function rawQuery(req) { const i = req.url.indexOf('?'); const out = {}; if (i < 0) return out; for (const part of req.url.slice(i + 1).split('&')) { if (!part) continue; const eq = part.indexOf('='); const dec = (s) => { try { return decodeURIComponent(s.replace(/\+/g, '%20')); } catch { return s; } }; const k = dec(eq < 0 ? part : part.slice(0, eq)); out[k] = eq < 0 ? '' : dec(part.slice(eq + 1)); } return out; } // Throws with an S3-style code when the signature does not hold. export function verify(req, url, auth, secretKey, now = Date.now()) { if (auth.region !== REGION || auth.service !== SERVICE) throw sigError('AuthorizationHeaderMalformed', `use region ${REGION}`); const t = parseAmzDate(auth.date); if (!Number.isFinite(t) || auth.date.slice(0, 8) !== auth.day) throw sigError('AccessDenied', 'missing or inconsistent X-Amz-Date'); if (auth.presigned) { if (!(auth.expires > 0 && auth.expires <= 604800)) throw sigError('AuthorizationQueryParametersError', 'bad X-Amz-Expires'); if (now > t + auth.expires * 1000) throw sigError('AccessDenied', 'Request has expired'); if (t - now > MAX_SKEW_MS) throw sigError('RequestTimeTooSkewed', 'request date is in the future'); } else if (Math.abs(now - t) > MAX_SKEW_MS) { throw sigError('RequestTimeTooSkewed', 'the difference between the request time and the server time is too large'); } const query = rawQuery(req); if (auth.presigned) delete query['X-Amz-Signature']; const names = auth.signedHeaders.split(';').filter(Boolean); if (!names.includes('host')) throw sigError('AccessDenied', 'host must be signed'); const headerLines = names.map((n) => { const v = req.headers[n]; if (v == null) throw sigError('AccessDenied', `signed header ${n} is missing`); return `${n}:${String(Array.isArray(v) ? v.join(',') : v).trim().replace(/\s+/g, ' ')}\n`; }).join(''); const payload = auth.presigned ? 'UNSIGNED-PAYLOAD' : (req.headers['x-amz-content-sha256'] || 'UNSIGNED-PAYLOAD'); const canonical = [req.method, canonicalPath(url.pathname === '' ? '/' : req.url.split('?')[0]), canonicalQuery(query), headerLines, auth.signedHeaders, payload].join('\n'); const scope = `${auth.day}/${REGION}/${SERVICE}/aws4_request`; const toSign = ['AWS4-HMAC-SHA256', auth.date, scope, crypto.createHash('sha256').update(canonical).digest('hex')].join('\n'); const want = crypto.createHmac('sha256', signingKey(secretKey, auth.day)).update(toSign).digest(); const got = Buffer.from(auth.signature, 'hex'); if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) { throw sigError('SignatureDoesNotMatch', 'The request signature we calculated does not match the signature you provided'); } } export class SigError extends Error { constructor(code, message, status = 403) { super(message); this.code = code; this.status = status; } } function sigError(code, message) { return new SigError(code, message, code === 'AuthorizationHeaderMalformed' || code === 'AuthorizationQueryParametersError' ? 400 : 403); }