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.
77 lines
3.6 KiB
JavaScript
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)}`;
|
|
}
|