pithos/hosted/sigv4.js
Local Dev d00bd369b7 Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.

Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00

96 lines
5.1 KiB
JavaScript

// 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); }