pithos/web/lib/sigv4.js
Local Dev 2eedcb8ff6 Pithos browser app, phase 1: encryption and S3 signing on Web Crypto
The browser app must read and write the same encrypted files as the
desktop Pithos. web/lib/crypt.js reimplements core/crypt.js on Web
Crypto (AES-SIV and CMAC built from AES-CBC with a zero IV and AES-CTR,
since Web Crypto has neither), and web/lib/sigv4.js the SigV4 signing.
Cross-tests: the RFC 5297 vector, identical drive keys and names, bodies
encrypted on one side and opened on the other (empty, chunk-edge and
multi-chunk files, ranges), tamper and wrong-path rejection, and equal
SigV4 headers and presigned URLs.
2026-10-05 02:14:43 +02:00

77 lines
3.6 KiB
JavaScript

// AWS SigV4 for the browser app: the same canonical form as core/s3.js
// (path-style, us-east-1, UNSIGNED-PAYLOAD for bodies), on Web Crypto.
// Browsers set Host themselves, so it is signed but not returned.
const subtle = globalThis.crypto.subtle;
const enc = new TextEncoder();
export const REGION = 'us-east-1';
export const SERVICE = 's3';
const hex = (b) => Array.from(new Uint8Array(b), (x) => x.toString(16).padStart(2, '0')).join('');
const sha256 = async (s) => hex(await subtle.digest('SHA-256', typeof s === 'string' ? enc.encode(s) : s));
async function hmac(key, msg) {
const k = await subtle.importKey('raw', typeof key === 'string' ? enc.encode(key) : key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return new Uint8Array(await subtle.sign('HMAC', k, enc.encode(msg)));
}
export function uriEncode(str, keepSlash) {
const out = encodeURIComponent(str).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
return keepSlash ? out.replace(/%2F/g, '/') : out;
}
export function canonicalQuery(query) {
return Object.keys(query).sort().map((k) => `${uriEncode(k)}=${uriEncode(String(query[k] ?? ''))}`).join('&');
}
export const amzDate = (d = new Date()) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
export function objectPath(bucket, key) {
return '/' + uriEncode(bucket) + (key != null ? '/' + uriEncode(key, true) : '');
}
const signingKeys = new Map();
async function signingKey(secret, date) {
const id = `${date}:${secret}`;
let k = signingKeys.get(id);
if (!k) {
k = await hmac(await hmac(await hmac(await hmac('AWS4' + secret, date), REGION), SERVICE), 'aws4_request');
signingKeys.clear();
signingKeys.set(id, k);
}
return k;
}
// Headers to send (lower-case, without host) for a signed request.
export async function sign({ host, accessKeyId, secretKey }, method, path, query = {}, headers = {}, { now = amzDate(), payloadHash = 'UNSIGNED-PAYLOAD' } = {}) {
const date = now.slice(0, 8);
const h = { ...headers, host, 'x-amz-date': now, 'x-amz-content-sha256': payloadHash };
const lower = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), String(v).trim()]));
const names = Object.keys(lower).sort();
const signed = names.join(';');
const canonical = [method, path, canonicalQuery(query), names.map((n) => `${n}:${lower[n]}\n`).join(''), signed, payloadHash].join('\n');
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
const toSign = ['AWS4-HMAC-SHA256', now, scope, await sha256(canonical)].join('\n');
const sig = hex(await hmac(await signingKey(secretKey, date), toSign));
lower.authorization = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${sig}`;
delete lower.host;
return lower;
}
// A presigned GET (for <audio>/<video>/<img> sources and downloads).
export async function presign({ origin, host, accessKeyId, secretKey }, bucket, key, expiresSec = 3600, now = amzDate()) {
const date = now.slice(0, 8);
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
const path = objectPath(bucket, key);
const query = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${accessKeyId}/${scope}`,
'X-Amz-Date': now,
'X-Amz-Expires': String(Math.min(Math.max(1, expiresSec | 0), 604800)),
'X-Amz-SignedHeaders': 'host',
};
const canonical = ['GET', path, canonicalQuery(query), `host:${host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
const toSign = ['AWS4-HMAC-SHA256', now, scope, await sha256(canonical)].join('\n');
query['X-Amz-Signature'] = hex(await hmac(await signingKey(secretKey, date), toSign));
return `${origin}${path}?${canonicalQuery(query)}`;
}