Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ curl -H "RFETCH_CONFIG_R2_TYPE: s3" \

With `Accept: application/json`, a `GET` gives the list of `rfetch lsjson`, and
`Depth: N` lists N levels. Else a folder is an HTML page, and a file is its
bytes, with ranges. The prefix `RCLONE_` of rclone also works.
bytes, with ranges. The prefix `RCLONE_` of rclone also works. The fields of the
query are options too. A header or a field of the query with an empty value is
not an option: the default of the option stays.

The Worker is also a WebDAV server, as `rfetch serve webdav`: a WebDAV client
can use the URL of a remote, for example `https://rfetch.pages.dev/r2:bucket/`,
Expand Down
11 changes: 8 additions & 3 deletions _worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
* `RFETCH_*`, or `RCLONE_*` for compatibility, for example
* `RFETCH_S3_ACCESS_KEY_ID` or the remote `RFETCH_CONFIG_R2_TYPE: s3`. The
* fields of the query are options of the remote too, as the parameters of a
* connection string. Do not put a secret in the query: a URL can stay in the
* history of a browser and in logs.
* connection string. A header or a field with an empty value is not an
* option. Do not put a secret in the query: a URL can stay in the history of
* a browser and in logs.
*
* A `GET` with `Accept: application/json` gives the list of `rfetch lsjson`.
* The header `Depth: N` lists N levels, and `Depth: infinity` all of them.
Expand Down Expand Up @@ -60,6 +61,8 @@ function locationOf(url) {
}
let params = "";
for (const [key, value] of url.searchParams) {
// A field with an empty value is not an option, as a header.
if (value === "") continue;
params += `,${key}='${value.replaceAll("'", "''")}'`;
}
// The remote ends with ":": the parameters go before it.
Expand Down Expand Up @@ -146,7 +149,9 @@ export default {

/** @type {RequestInit} */
const init = { method, headers };
if (request.body) {
// workerd gives a body stream to a GET with `Content-Length: 0`, and a
// GET or a HEAD with a body is an error of `fetch()`.
if (request.body && method !== "GET" && method !== "HEAD") {
init.body = request.body;
init.duplex = "half";
}
Expand Down
244 changes: 237 additions & 7 deletions backend/crypt/aes.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,251 @@
import { createCipheriv, createDecipheriv } from "node:crypto";
import * as nodeCrypto from "node:crypto";

const BLOCK_SIZE = 16;
const ROUNDS = 14;

/**
* AES-256 of `node:crypto` in ECB mode with no padding, the block cipher of
* EME. Each call takes one or more blocks of 16 bytes. One object does all
* the calls of one direction, because ECB keeps no state between blocks.
* AES-256 in ECB mode with no padding, the block cipher of EME. Each call
* takes one or more blocks of 16 bytes.
*
* It uses AES-256-ECB of `node:crypto`. One object does all the calls of one
* direction, because ECB keeps no state between blocks. A runtime with no AES
* in `node:crypto` gets `aes256js`: in a build of Cloudflare Pages,
* `createCipheriv` is a stub of unenv that throws.
* @param {Uint8Array} key 32 bytes.
* @param {Pick<typeof nodeCrypto, "createCipheriv" | "createDecipheriv">} [native]
* The functions of `node:crypto`. A test gives others.
* @returns {import("./eme.js").BlockCipher}
*/
export function aes256(key) {
export function aes256(key, native = nodeCrypto) {
// ECB has no IV. workerd refuses null, so the IV is empty.
const iv = new Uint8Array(0);
const encryptor = createCipheriv("aes-256-ecb", key, iv);
const decryptor = createDecipheriv("aes-256-ecb", key, iv);
let encryptor;
let decryptor;
try {
encryptor = native.createCipheriv("aes-256-ecb", key, iv);
decryptor = native.createDecipheriv("aes-256-ecb", key, iv);
} catch {
return aes256js(key);
}
encryptor.setAutoPadding(false);
decryptor.setAutoPadding(false);
return {
encrypt: (block) => encryptor.update(block),
decrypt: (block) => decryptor.update(block),
};
}

/**
* AES-256 in ECB mode with no padding, in JavaScript, as FIPS-197. It is
* slower than `node:crypto`, and its tables do not keep the time the same for
* each key, but it runs in each runtime. EME uses it only for the names of
* files.
* @param {Uint8Array} key 32 bytes.
* @returns {import("./eme.js").BlockCipher}
*/
export function aes256js(key) {
if (key.length !== 32) {
throw new RangeError("The key of AES-256 must have 32 bytes");
}
const { sbox, inverse } = tables();
const w = expand(key, sbox);
return {
encrypt: (blocks) => each(blocks, (s) => encryptBlock(s, w, sbox)),
decrypt: (blocks) => each(blocks, (s) => decryptBlock(s, w, inverse)),
};
}

/**
* Gives a copy of the blocks, with each block changed in place by `fn`.
* @param {Uint8Array} blocks
* @param {(state: Uint8Array) => void} fn
* @returns {Uint8Array}
*/
function each(blocks, fn) {
if (blocks.length % BLOCK_SIZE !== 0) {
throw new RangeError("The data of AES-ECB must be blocks of 16 bytes");
}
// A copy: slice() of a Buffer gives a view of the same bytes.
const out = new Uint8Array(blocks);
for (let i = 0; i < out.length; i += BLOCK_SIZE) {
fn(out.subarray(i, i + BLOCK_SIZE));
}
return out;
}

/** @type {{ sbox: Uint8Array, inverse: Uint8Array } | undefined} */
let cache;

/**
* The S-box of AES and its inverse (FIPS-197, section 5.1.1): the inverse in
* GF(2^8), then the affine transform. They are made at the first use, not at
* the load of the module. p goes through each value other than 0 with the
* generator 3, and q is its inverse.
*/
function tables() {
if (cache) return cache;
const sbox = new Uint8Array(256);
const inverse = new Uint8Array(256);
const rotl = (x, n) => ((x << n) | (x >> (8 - n))) & 0xff;
let p = 1;
let q = 1;
do {
// p = p * 3.
p = p ^ xtime(p);
// q = q / 3.
q ^= q << 1;
q ^= q << 2;
q ^= q << 4;
q &= 0xff;
if (q & 0x80) q ^= 0x09;
const s = q ^ rotl(q, 1) ^ rotl(q, 2) ^ rotl(q, 3) ^ rotl(q, 4) ^ 0x63;
sbox[p] = s;
inverse[s] = p;
} while (p !== 1);
// 0 has no inverse: the affine transform of 0.
sbox[0] = 0x63;
inverse[0x63] = 0;
cache = { sbox, inverse };
return cache;
}

/**
* The multiplication by 2 in GF(2^8).
* @param {number} x A byte.
*/
function xtime(x) {
return ((x << 1) ^ (x & 0x80 ? 0x1b : 0)) & 0xff;
}

/**
* The key schedule of AES-256 (FIPS-197, section 5.2): 15 round keys of 16
* bytes, one after the other.
* @param {Uint8Array} key 32 bytes.
* @param {Uint8Array} sbox
*/
function expand(key, sbox) {
const w = new Uint8Array((ROUNDS + 1) * BLOCK_SIZE);
w.set(key);
let rcon = 1;
for (let i = 32; i < w.length; i += 4) {
let t0 = w[i - 4];
let t1 = w[i - 3];
let t2 = w[i - 2];
let t3 = w[i - 1];
if (i % 32 === 0) {
// RotWord, SubWord and Rcon.
[t0, t1, t2, t3] = [sbox[t1] ^ rcon, sbox[t2], sbox[t3], sbox[t0]];
rcon = xtime(rcon);
} else if (i % 32 === 16) {
// SubWord only, for a key of 8 words.
[t0, t1, t2, t3] = [sbox[t0], sbox[t1], sbox[t2], sbox[t3]];
}
w[i] = w[i - 32] ^ t0;
w[i + 1] = w[i - 31] ^ t1;
w[i + 2] = w[i - 30] ^ t2;
w[i + 3] = w[i - 29] ^ t3;
}
return w;
}

/**
* @param {Uint8Array} s The state: 16 bytes, column after column.
* @param {Uint8Array} w The round keys.
* @param {number} round
*/
function addRoundKey(s, w, round) {
const start = round * BLOCK_SIZE;
for (let i = 0; i < BLOCK_SIZE; i++) s[i] ^= w[start + i];
}

/**
* Puts each byte of the state through a table.
* @param {Uint8Array} s
* @param {Uint8Array} table
*/
function substitute(s, table) {
for (let i = 0; i < BLOCK_SIZE; i++) s[i] = table[s[i]];
}

/**
* Turns the row r of the state left by r bytes, or right with `inverse`.
* @param {Uint8Array} s
* @param {boolean} inverse
*/
function shiftRows(s, inverse) {
const t = s.slice();
for (let r = 1; r < 4; r++) {
for (let c = 0; c < 4; c++) {
const from = inverse ? (c + 4 - r) % 4 : (c + r) % 4;
s[r + 4 * c] = t[r + 4 * from];
}
}
}

/** @param {Uint8Array} s */
function mixColumns(s) {
for (let c = 0; c < 16; c += 4) {
const [a0, a1, a2, a3] = [s[c], s[c + 1], s[c + 2], s[c + 3]];
const all = a0 ^ a1 ^ a2 ^ a3;
s[c] = a0 ^ all ^ xtime(a0 ^ a1);
s[c + 1] = a1 ^ all ^ xtime(a1 ^ a2);
s[c + 2] = a2 ^ all ^ xtime(a2 ^ a3);
s[c + 3] = a3 ^ all ^ xtime(a3 ^ a0);
}
}

/**
* The inverse of `mixColumns`: the multiplications by 9, 11, 13 and 14.
* @param {Uint8Array} s
*/
function inverseMixColumns(s) {
for (let c = 0; c < 16; c += 4) {
const a = [s[c], s[c + 1], s[c + 2], s[c + 3]];
const times = a.map((x) => {
const x2 = xtime(x);
const x4 = xtime(x2);
const x8 = xtime(x4);
return { 9: x8 ^ x, 11: x8 ^ x2 ^ x, 13: x8 ^ x4 ^ x, 14: x8 ^ x4 ^ x2 };
});
for (let r = 0; r < 4; r++) {
s[c + r] = times[r][14] ^ times[(r + 1) % 4][11] ^
times[(r + 2) % 4][13] ^ times[(r + 3) % 4][9];
}
}
}

/**
* @param {Uint8Array} s One block, which changes.
* @param {Uint8Array} w
* @param {Uint8Array} sbox
*/
function encryptBlock(s, w, sbox) {
addRoundKey(s, w, 0);
for (let round = 1; round < ROUNDS; round++) {
substitute(s, sbox);
shiftRows(s, false);
mixColumns(s);
addRoundKey(s, w, round);
}
substitute(s, sbox);
shiftRows(s, false);
addRoundKey(s, w, ROUNDS);
}

/**
* @param {Uint8Array} s One block, which changes.
* @param {Uint8Array} w
* @param {Uint8Array} inverse The inverse S-box.
*/
function decryptBlock(s, w, inverse) {
addRoundKey(s, w, ROUNDS);
for (let round = ROUNDS - 1; round > 0; round--) {
shiftRows(s, true);
substitute(s, inverse);
addRoundKey(s, w, round);
inverseMixColumns(s);
}
shiftRows(s, true);
substitute(s, inverse);
addRoundKey(s, w, 0);
}
66 changes: 66 additions & 0 deletions backend/crypt/aes_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fc from "fast-check";

import { aes256, aes256js } from "./aes.js";

/** @param {string} text */
const hex = (text) => Uint8Array.from(Buffer.from(text, "hex"));

test("AES-256 in JavaScript gives the vector of FIPS-197, appendix C.3", () => {
const key = hex(
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
);
const plain = hex("00112233445566778899aabbccddeeff");
const cipher = hex("8ea2b7ca516745bfeafc49904b496089");
const aes = aes256js(key);
assert.deepEqual(aes.encrypt(plain), cipher);
assert.deepEqual(aes.decrypt(cipher), plain);
// The input does not change.
assert.deepEqual(plain, hex("00112233445566778899aabbccddeeff"));
});

const KEY = fc.uint8Array({ minLength: 32, maxLength: 32 });
const BLOCKS = fc.integer({ min: 1, max: 8 }).chain((blocks) =>
fc.uint8Array({ minLength: blocks * 16, maxLength: blocks * 16 })
);

test("AES-256 in JavaScript gives the blocks of node:crypto", () => {
fc.assert(
fc.property(KEY, BLOCKS, (key, blocks) => {
const native = aes256(key);
const js = aes256js(key);
const encrypted = native.encrypt(blocks);
assert.deepEqual(js.encrypt(blocks), new Uint8Array(encrypted));
assert.deepEqual(js.decrypt(encrypted), blocks);
}),
);
});

test("aes256 uses JavaScript when node:crypto has no AES", () => {
// As createCipheriv of unenv in a build of Cloudflare Pages.
const stub = {
createCipheriv() {
throw new Error("[unenv] crypto.createCipheriv is not implemented yet!");
},
createDecipheriv() {
throw new Error(
"[unenv] crypto.createDecipheriv is not implemented yet!",
);
},
};
const key = new Uint8Array(32).fill(7);
const blocks = new Uint8Array(32).fill(1);
const fallback = aes256(key, stub);
const encrypted = fallback.encrypt(blocks);
assert.deepEqual(encrypted, new Uint8Array(aes256(key).encrypt(blocks)));
assert.deepEqual(fallback.decrypt(encrypted), blocks);
});

test("AES-256 in JavaScript refuses a key or data of a wrong size", () => {
assert.throws(() => aes256js(new Uint8Array(16)), RangeError);
assert.throws(
() => aes256js(new Uint8Array(32)).encrypt(new Uint8Array(15)),
RangeError,
);
});
19 changes: 11 additions & 8 deletions backend/crypt/eme_test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test } from "node:test";
import { aes256 } from "./aes.js";
import { aes256, aes256js } from "./aes.js";
import assert from "node:assert/strict";
import fc from "fast-check";

Expand Down Expand Up @@ -96,13 +96,16 @@ const VECTORS = {
`),
};

test("the vectors of rfjakob/eme", () => {
const cipher = aes256(new Uint8Array(32));
const tweak = new Uint8Array(16);
for (const [size, expected] of Object.entries(VECTORS)) {
const zeros = new Uint8Array(Number(size));
assert.deepEqual(encrypt(cipher, tweak, zeros), expected);
assert.deepEqual(decrypt(cipher, tweak, expected), zeros);
test("the vectors of rfjakob/eme, with AES of node:crypto and of JavaScript", () => {
for (
const cipher of [aes256(new Uint8Array(32)), aes256js(new Uint8Array(32))]
) {
const tweak = new Uint8Array(16);
for (const [size, expected] of Object.entries(VECTORS)) {
const zeros = new Uint8Array(Number(size));
assert.deepEqual(new Uint8Array(encrypt(cipher, tweak, zeros)), expected);
assert.deepEqual(new Uint8Array(decrypt(cipher, tweak, expected)), zeros);
}
}
});

Expand Down
Loading
Loading