From 2eedcb8ff63338cac2b7ddc5784c9861cfe303f9 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 5 Oct 2026 02:14:43 +0200 Subject: [PATCH] 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. --- test/web-crypt.test.js | 108 ++++++++++++++ web/lib/crypt.js | 312 +++++++++++++++++++++++++++++++++++++++++ web/lib/sigv4.js | 77 ++++++++++ 3 files changed, 497 insertions(+) create mode 100644 test/web-crypt.test.js create mode 100644 web/lib/crypt.js create mode 100644 web/lib/sigv4.js diff --git a/test/web-crypt.test.js b/test/web-crypt.test.js new file mode 100644 index 0000000..ca13baf --- /dev/null +++ b/test/web-crypt.test.js @@ -0,0 +1,108 @@ +// The browser app's crypto (web/lib) must match core/ byte for byte: files +// written by the desktop Pithos open in the browser and the other way round. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { Readable } from 'node:stream'; +import * as N from '../core/crypt.js'; +import { S3Client } from '../core/s3.js'; +import * as W from '../web/lib/crypt.js'; +import * as WS from '../web/lib/sigv4.js'; + +const H = (s) => Buffer.from(s.replace(/\s+/g, ''), 'hex'); +const collect = async (it) => { const parts = []; for await (const p of it) parts.push(Buffer.from(p)); return Buffer.concat(parts); }; + +test('AES-SIV matches RFC 5297 A.1 and Node', async () => { + const key = H('fffefdfc fbfaf9f8 f7f6f5f4 f3f2f1f0 f0f1f2f3 f4f5f6f7 f8f9fafb fcfdfeff'); + const ad = H('10111213 14151617 18191a1b 1c1d1e1f 20212223 24252627'); + const pt = H('11223344 55667788 99aabbcc ddee'); + const want = '85632d07c6e8f37f950acd320a2ecc9340c02b9690c4dc04daef7f6afe5c'; + assert.equal(Buffer.from(await W.sivEncrypt(new Uint8Array(key), [new Uint8Array(ad)], new Uint8Array(pt))).toString('hex'), want); + assert.equal(N.sivEncrypt(key, [ad], pt).toString('hex'), want); + const bad = Uint8Array.from(H(want)); bad[20] ^= 1; + assert.equal(await W.sivDecrypt(new Uint8Array(key), [new Uint8Array(ad)], bad), null); + for (const len of [0, 1, 15, 16, 17, 31, 32, 33, 100]) { + const m = crypto.randomBytes(len); + assert.equal(Buffer.from(await W.cmac(new Uint8Array(key.subarray(0, 32)), new Uint8Array(m))).toString('hex'), N.cmac(key.subarray(0, 32), m).toString('hex'), `cmac ${len}`); + } +}); + +test('drive keys and encrypted names are identical, and decrypt across', async () => { + const master = crypto.randomBytes(32); + const n = N.driveKeys(master, 'music-house'); + const w = await W.driveKeys(new Uint8Array(master), 'music-house'); + assert.equal(Buffer.from(w.name).toString('hex'), n.name.toString('hex')); + assert.equal(Buffer.from(w.body).toString('hex'), n.body.toString('hex')); + for (const p of ['a.mp3', 'photos/2026/Ünïcødé – long name with spaces.jpg', 'folder/', 'x/y/z/']) { + const nk = N.encryptPath(n, p); + assert.equal(await W.encryptPath(w, p), nk, p); + assert.deepEqual(await W.decryptPath(w, nk), { path: p, encrypted: true }); + assert.deepEqual(N.decryptPath(n, await W.encryptPath(w, p)), { path: p, encrypted: true }); + } + // plain names stay plain, mixed paths say so + assert.deepEqual(await W.decryptPath(w, 'public/readme.txt'), { path: 'public/readme.txt', encrypted: false }); + assert.deepEqual(await W.decryptPath(w, '~notours'), { path: '~notours', encrypted: false }); + // another drive's names do not open + const other = await W.driveKeys(new Uint8Array(master), 'other'); + assert.equal((await W.decryptPath(other, N.encryptPath(n, 'a.mp3'))).encrypted, false); +}); + +test('file bodies decrypt across, whole and by range', async () => { + const master = crypto.randomBytes(32); + const n = N.driveKeys(master, 'docs'); + const w = await W.driveKeys(new Uint8Array(master), 'docs'); + const MiB = 1 << 20; + for (const size of [0, 1, 1000, MiB, MiB + 5, Math.floor(2.5 * MiB)]) { + const plain = crypto.randomBytes(size); + const path = `dir/f-${size}.bin`; + + // Node encrypts, the browser decrypts (whole file, then a range across a chunk edge) + const { stream, size: csize } = N.encryptBody(n, path, Readable.from([plain]), size); + const cipher = await collect(stream); + assert.equal(cipher.length, csize); + assert.equal(W.cipherSize(size), csize); + assert.equal(W.plainSize(csize), size); + const header = new Uint8Array(cipher.subarray(0, W.HEADER_LEN)); + const ctx = await W.bodyContext(w, path, header); + const read = (r) => (i) => new Uint8Array(cipher.subarray(W.HEADER_LEN + i * (ctx.cs + W.TAG_LEN), Math.min(cipher.length, W.HEADER_LEN + (i + 1) * (ctx.cs + W.TAG_LEN)))); + const whole = W.cipherRange(cipher.length); + assert.ok((await collect(W.decryptChunks(ctx, whole, read(whole)))).equals(plain), `whole ${size}`); + if (size > MiB) { + const r = W.cipherRange(cipher.length, { start: MiB - 3, end: MiB + 3 }); + assert.ok((await collect(W.decryptChunks(ctx, r, read(r)))).equals(plain.subarray(MiB - 3, MiB + 4)), `range ${size}`); + assert.deepEqual(r, N.cipherRange(cipher.length, { start: MiB - 3, end: MiB + 3 })); + } + + // the browser encrypts, Node decrypts + const wc = await collect(W.encryptBlob(w, path, new Blob([plain]))); + assert.equal(wc.length, csize); + const back = await collect(N.decryptBody(n, path, wc.subarray(0, N.HEADER_LEN), Readable.from([wc.subarray(N.HEADER_LEN)]), N.cipherRange(wc.length))); + assert.ok(back.equals(plain), `browser→node ${size}`); + } +}); + +test('a body moved to another path or changed does not open', async () => { + const master = crypto.randomBytes(32); + const w = await W.driveKeys(new Uint8Array(master), 'docs'); + const c = await collect(W.encryptBlob(w, 'a.txt', new Blob(['hello']))); + const header = new Uint8Array(c.subarray(0, W.HEADER_LEN)); + const sealed = new Uint8Array(c.subarray(W.HEADER_LEN)); + await assert.rejects(W.openChunk(await W.bodyContext(w, 'b.txt', header), 0, true, sealed)); + const flipped = Uint8Array.from(sealed); flipped[0] ^= 1; + await assert.rejects(W.openChunk(await W.bodyContext(w, 'a.txt', header), 0, true, flipped)); + assert.equal(Buffer.from(await W.openChunk(await W.bodyContext(w, 'a.txt', header), 0, true, sealed)).toString(), 'hello'); +}); + +test('SigV4 headers and presigned URLs match core/s3.js', async () => { + const creds = { accessKeyId: 'AKIDEXAMPLE1234', secretKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' }; + const node = new S3Client({ endpoint: 'https://pithos.silentmode.st', ...creds }); + const now = '20261005T120000Z'; + const query = { 'list-type': '2', prefix: '~abc/', delimiter: '/', 'max-keys': '1000' }; + const n = node.sign('GET', '/my-drive', query, { range: 'bytes=0-99' }, { now }); + const w = await WS.sign({ host: 'pithos.silentmode.st', ...creds }, 'GET', '/my-drive', query, { range: 'bytes=0-99' }, { now }); + assert.deepEqual(w, n); + assert.equal(WS.objectPath('b', 'a dir/ä+b.txt'), '/b/a%20dir/%C3%A4%2Bb.txt'); + assert.equal( + await WS.presign({ origin: 'https://pithos.silentmode.st', host: 'pithos.silentmode.st', ...creds }, 'b', 'x/y.mp3', 600, now), + node.presign('b', 'x/y.mp3', 600, undefined, now)); +}); diff --git a/web/lib/crypt.js b/web/lib/crypt.js new file mode 100644 index 0000000..f355ad0 --- /dev/null +++ b/web/lib/crypt.js @@ -0,0 +1,312 @@ +// The browser side of core/crypt.js: the same formats, byte for byte, on Web +// Crypto (so this file also runs under Node's test runner). See core/crypt.js +// for the design; only what differs is explained here. +// +// Web Crypto has no AES-ECB, CMAC or AES-SIV. One-block ECB and CMAC come from +// AES-CBC with a zero IV (Web Crypto adds a PKCS#7 block at the end, which is +// dropped); SIV's counter mode is AES-CTR over the full 128-bit block, as +// Node's aes-ctr does. + +const subtle = globalThis.crypto.subtle; +const enc = new TextEncoder(); +const dec = new TextDecoder('utf-8', { fatal: true }); + +export const HEADER_LEN = 24; +export const TAG_LEN = 16; +const MAGIC = [0x50, 0x54, 0x45, 0x31]; // "PTE1" +const LOG2_CHUNK = 20; // 1 MiB +const ZERO = new Uint8Array(16); + +const u8 = (x) => (x instanceof Uint8Array ? x : new Uint8Array(x)); +export const utf8 = (s) => enc.encode(s); +export function concat(...parts) { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; +} + +// ---- keys as CryptoKeys, cached per raw key ---------------------------------- + +const keyCache = new Map(); +async function importKey(raw, alg, usages) { + const id = `${alg}:${b64u(raw)}`; + let k = keyCache.get(id); + if (!k) { + k = await subtle.importKey('raw', raw, alg, false, usages); + if (keyCache.size > 256) keyCache.clear(); + keyCache.set(id, k); + } + return k; +} + +// ---- AES-SIV (RFC 5297), AES-CMAC (RFC 4493) --------------------------------- + +async function cbc(key, data) { + const out = u8(await subtle.encrypt({ name: 'AES-CBC', iv: ZERO }, await importKey(key, 'AES-CBC', ['encrypt']), data)); + return out.subarray(0, data.length); // drop the PKCS#7 block +} + +function dbl(b) { + const out = new Uint8Array(16); + let carry = 0; + for (let i = 15; i >= 0; i--) { + out[i] = ((b[i] << 1) | carry) & 0xff; + carry = b[i] >> 7; + } + if (b[0] & 0x80) out[15] ^= 0x87; + return out; +} + +function xor(a, b) { + const out = new Uint8Array(a.length); + for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i]; + return out; +} + +export async function cmac(key, msg) { + const k1 = dbl(await cbc(key, ZERO)); + const k2 = dbl(k1); + const n = Math.max(1, Math.ceil(msg.length / 16)); + const complete = msg.length > 0 && msg.length % 16 === 0; + const last = new Uint8Array(16); + last.set(msg.subarray((n - 1) * 16)); + if (!complete) last[msg.length - (n - 1) * 16] = 0x80; + const m = concat(msg.subarray(0, (n - 1) * 16), xor(last, complete ? k1 : k2)); + const out = await cbc(key, m); + return out.subarray(out.length - 16); +} + +async function s2v(key, ads, plain) { + let d = await cmac(key, ZERO); + for (const ad of ads) d = xor(dbl(d), await cmac(key, ad)); + let t; + if (plain.length >= 16) { + t = Uint8Array.from(plain); + const tail = t.length - 16; + for (let i = 0; i < 16; i++) t[tail + i] ^= d[i]; + } else { + const padded = new Uint8Array(16); + padded.set(plain); + padded[plain.length] = 0x80; + t = xor(dbl(d), padded); + } + return cmac(key, t); +} + +async function sivCtr(key, v, data) { + const q = Uint8Array.from(v); + q[8] &= 0x7f; + q[12] &= 0x7f; + return u8(await subtle.encrypt({ name: 'AES-CTR', counter: q, length: 128 }, await importKey(key, 'AES-CTR', ['encrypt']), data)); +} + +function equal(a, b) { + if (a.length !== b.length) return false; + let d = 0; + for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; + return d === 0; +} + +// key: 32, 48 or 64 bytes (two AES keys). Returns tag || ciphertext. +export async function sivEncrypt(key, ads, plain) { + const half = key.length / 2; + const v = await s2v(key.subarray(0, half), ads, plain); + return concat(v, await sivCtr(key.subarray(half), v, plain)); +} + +// The plaintext, or null when the tag does not match. +export async function sivDecrypt(key, ads, data) { + if (data.length < 16) return null; + const half = key.length / 2; + const v = data.subarray(0, 16); + const plain = await sivCtr(key.subarray(half), v, data.subarray(16)); + return equal(await s2v(key.subarray(0, half), ads, plain), v) ? plain : null; +} + +// ---- keys ------------------------------------------------------------------------ + +export async function hkdf(ikm, salt, info, len) { + const k = await subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']); + return u8(await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: utf8(info) }, k, len * 8)); +} + +export async function driveKeys(master, bucket) { + if (!(master instanceof Uint8Array) || master.length !== 32) throw new Error('the encryption key must be 32 bytes'); + const okm = await hkdf(master, new Uint8Array(0), `pithos/drive/v1/${bucket}`, 96); + return { bucket, name: okm.subarray(0, 64), body: okm.subarray(64) }; +} + +// ---- names ----------------------------------------------------------------------- + +export function b64u(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function unb64u(s) { + if (!/^[A-Za-z0-9_-]*$/.test(s)) return null; + const pad = s.length % 4 === 2 ? '==' : s.length % 4 === 3 ? '=' : s.length % 4 === 1 ? null : ''; + if (pad == null) return null; + try { return Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad), (c) => c.charCodeAt(0)); } catch { return null; } +} + +const segmentAds = (dk, parent) => [utf8(dk.bucket), utf8(parent)]; + +export async function encryptSegment(dk, parent, seg) { + return '~' + b64u(await sivEncrypt(dk.name, segmentAds(dk, parent), utf8(seg))); +} + +// null when the segment is not one of ours (a plain name). +export async function decryptSegment(dk, parent, seg) { + if (!seg.startsWith('~') || seg.length < 23) return null; + const raw = unb64u(seg.slice(1)); + if (!raw || b64u(raw) !== seg.slice(1)) return null; + const p = await sivDecrypt(dk.name, segmentAds(dk, parent), raw); + if (!p) return null; + try { return dec.decode(p); } catch { return null; } +} + +export async function encryptPath(dk, plain) { + if (!plain) return ''; + const folder = plain.endsWith('/'); + const segs = (folder ? plain.slice(0, -1) : plain).split('/'); + const out = []; + let parent = ''; + for (const s of segs) { + if (!s) throw new Error('empty folder names are not supported in encrypted drives'); + out.push(await encryptSegment(dk, parent, s)); + parent += s + '/'; + } + const key = out.join('/') + (folder ? '/' : ''); + if (utf8(key).length > 1024) throw new Error('this name is too long once encrypted (S3 allows 1024 bytes)'); + return key; +} + +// { path, encrypted }: encrypted only if every segment was ours. +export async function decryptPath(dk, key) { + if (!key) return { path: '', encrypted: false }; + const folder = key.endsWith('/'); + const segs = (folder ? key.slice(0, -1) : key).split('/'); + const out = []; + let parent = ''; + let all = true; + for (const s of segs) { + const d = await decryptSegment(dk, parent, s); + if (d == null) all = false; + const plain = d ?? s; + out.push(plain); + parent += plain + '/'; + } + return { path: out.join('/') + (folder ? '/' : ''), encrypted: all }; +} + +// ---- bodies ------------------------------------------------------------------------ + +export const chunkSize = (log2 = LOG2_CHUNK) => 2 ** log2; + +export function cipherSize(plainSize, cs = chunkSize()) { + return HEADER_LEN + plainSize + Math.max(1, Math.ceil(plainSize / cs)) * TAG_LEN; +} + +export function plainSize(cipherSizeBytes, cs = chunkSize()) { + const c = cipherSizeBytes - HEADER_LEN; + if (c < TAG_LEN) return null; + const chunks = Math.ceil(c / (cs + TAG_LEN)); + const p = c - chunks * TAG_LEN; + return p >= 0 && cipherSize(p, cs) === cipherSizeBytes ? p : null; +} + +export function isEncryptedHeader(buf) { + return buf.length >= HEADER_LEN && MAGIC.every((b, i) => buf[i] === b); +} + +export function parseHeader(header) { + if (!isEncryptedHeader(header)) throw new Error('not an encrypted Pithos file'); + const log2 = header[20]; + if (log2 < 12 || log2 > 24) throw new Error('unsupported chunk size in an encrypted file'); + return { salt: header.subarray(4, 20), cs: 2 ** log2 }; +} + +export function newHeader() { + const h = new Uint8Array(HEADER_LEN); + h.set(MAGIC); + h.set(globalThis.crypto.getRandomValues(new Uint8Array(16)), 4); + h[20] = LOG2_CHUNK; + return h; +} + +// What sealing or opening one file's chunks needs. +export async function bodyContext(dk, plainPath, header) { + const { salt, cs } = parseHeader(header); + const raw = await hkdf(dk.body, salt, 'pithos/body/v1', 32); + const key = await subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']); + return { key, cs, ad: concat(header, utf8(dk.bucket + '\0' + plainPath)) }; +} + +function nonce(i, last) { + const n = new Uint8Array(12); + new DataView(n.buffer).setBigUint64(0, BigInt(i)); + n[8] = last ? 1 : 0; + return n; +} + +export async function sealChunk(ctx, i, last, piece) { + return u8(await subtle.encrypt({ name: 'AES-GCM', iv: nonce(i, last), additionalData: ctx.ad, tagLength: 128 }, ctx.key, piece)); +} + +export async function openChunk(ctx, i, last, sealed) { + try { + return u8(await subtle.decrypt({ name: 'AES-GCM', iv: nonce(i, last), additionalData: ctx.ad, tagLength: 128 }, ctx.key, sealed)); + } catch { + throw new Error('this file was changed on the server or belongs to another drive or key'); + } +} + +// Encrypts a Blob/File (or bytes) chunk by chunk without holding it whole: +// yields the header, then each sealed chunk. Total = cipherSize(size). +export async function* encryptBlob(dk, plainPath, blob) { + const src = blob instanceof Uint8Array ? new Blob([blob]) : blob; + const header = newHeader(); + const ctx = await bodyContext(dk, plainPath, header); + const chunks = Math.max(1, Math.ceil(src.size / ctx.cs)); + yield header; + for (let i = 0; i < chunks; i++) { + const piece = u8(await src.slice(i * ctx.cs, (i + 1) * ctx.cs).arrayBuffer()); + yield await sealChunk(ctx, i, i === chunks - 1, piece); + } +} + +// What to fetch for a plaintext range [start, end] of a cipherLen-byte file. +export function cipherRange(cipherLen, range, cs = chunkSize()) { + const plain = plainSize(cipherLen, cs); + if (plain == null) throw new Error('not an encrypted Pithos file (size does not match)'); + const chunks = Math.max(1, Math.ceil(plain / cs)); + let start = 0; + let end = plain - 1; + if (range) { + start = range.start; + end = Math.min(range.end ?? plain - 1, plain - 1); + if (start > end || start >= plain) return { plain, unsatisfiable: true }; + } + const first = plain ? Math.floor(start / cs) : 0; + const lastChunk = plain ? Math.floor(end / cs) : 0; + return { + plain, start, end, chunks, first, lastChunk, + from: HEADER_LEN + first * (cs + TAG_LEN), + to: Math.min(cipherLen - 1, HEADER_LEN + (lastChunk + 1) * (cs + TAG_LEN) - 1), + }; +} + +// Opens chunks r.first..r.lastChunk read from `readChunk(i)` (which returns the +// sealed bytes of chunk i) and yields only plaintext bytes r.start..r.end. +export async function* decryptChunks(ctx, r, readChunk) { + for (let i = r.first; i <= r.lastChunk; i++) { + const plain = await openChunk(ctx, i, i === r.chunks - 1, await readChunk(i)); + const base = i * ctx.cs; + const a = Math.max(0, r.start - base); + const b = Math.min(plain.length, r.end - base + 1); + if (b > a) yield plain.subarray(a, b); + } +} diff --git a/web/lib/sigv4.js b/web/lib/sigv4.js new file mode 100644 index 0000000..e9e9461 --- /dev/null +++ b/web/lib/sigv4.js @@ -0,0 +1,77 @@ +// 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