Skip to content

Commit

Permalink
Switch from CryptoJS to WebCrypto API
Browse files Browse the repository at this point in the history
  • Loading branch information
arjunyel committed Nov 30, 2023
1 parent 76fcb4b commit bd53d17
Show file tree
Hide file tree
Showing 8 changed files with 409 additions and 103 deletions.
50 changes: 39 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ Additional optional dependencies may be needed, all optional dependencies are:

- `@remix-run/react` (also `@remix-run/router` but you should be using the React one)
- `@remix-run/node` or `@remix-run/cloudflare` or `@remix-run/deno` (actually it's `@remix-run/server-runtime` but you should use one of the others)
- `crypto-js`
- `is-ip`
- `intl-parse-accept-language`
- `react`
Expand All @@ -23,7 +22,7 @@ The utils that require an extra optional dependency mention it in their document
If you want to install them all run:

```sh
npm add crypto-js is-ip intl-parse-accept-language zod
npm add is-ip intl-parse-accept-language zod
```

React and the `@remix-run/*` packages should be already installed in your project.
Expand Down Expand Up @@ -408,12 +407,26 @@ Additionally, the `cors` function accepts a `options` object as a third optional
### CSRF

> **Note**
> This depends on `react`, `crypto-js`, and a Remix server runtime.
> This depends on `react`, the [WebCrypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API), and a Remix server runtime.
The CSRF related functions let you implement CSRF protection on your application.

This part of Remix Utils needs React and server-side code.

The [WebCrypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is required. If your enviroment doesn't support it you must supply a polyfill, such as [`@peculiar/webcrypto`](https://github.com/PeculiarVentures/webcrypto), to either `globalThis.crypto` or the CSRF constructor.

```ts
import { Crypto } from "@peculiar/webcrypto";

globalThis.crypto = new Crypto();

// Or
export const csrf = new CSRF({
...otherCsrfOptions
webCrypto: new Crypto(),
});
```

First create a new CSRF instance.

```ts
Expand All @@ -433,8 +446,8 @@ export const csrf = new CSRF({
cookie,
// what key in FormData objects will be used for the token, defaults to `csrf`
formDataKey: "csrf",
// an optional secret used to sign the token, recommended for extra safety
secret: "s3cr3t",
// HMAC SHA-256 secret key, a string with 256 bits (32 bytes) of entropy
secret: "Required in production",
});
```

Expand All @@ -444,14 +457,14 @@ Then you can use `csrf` to generate a new token.
import { csrf } from "~/utils/csrf.server";

export async function loader({ request }: LoaderArgs) {
let token = csrf.generate();
let token = await csrf.generate();
}
```

You can customize the token size by passing the byte size, the default one is 32 bytes which will give you a string with a length of 43 after encoding.

```ts
let token = csrf.generate(64); // customize token length
let token = await csrf.generate(64); // customize token length
```

You will need to save this token in a cookie and also return it from the loader. For convenience, you can use the `CSRF#commitToken` helper.
Expand Down Expand Up @@ -1904,12 +1917,26 @@ This means that the `respondTo` helper will prioritize any handler that match `t
### Form Honeypot

> **Note**
> This depends on `react` and `crypto-js`.
> This depends on `react` and the [`WebCrypto API`](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API).
Honeypot is a simple technique to prevent spam bots from submitting forms. It works by adding a hidden field to the form that bots will fill, but humans won't.

There's a pair of utils in Remix Utils to help you implement this.

The [WebCrypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is required. If your enviroment doesn't support it you must supply a polyfill, such as [`@peculiar/webcrypto`](https://github.com/PeculiarVentures/webcrypto), to either `globalThis.crypto` or the honeypot constructor.

```ts
import { Crypto } from "@peculiar/webcrypto";

globalThis.crypto = new Crypto();

// Or
export const csrf = new Honeypot({
...otherHoneypotOptions
webCrypto: new Crypto(),
});
```

First, create a `honeypot.server.ts` where you will instantiate and configure your Honeypot.

```tsx
Expand All @@ -1921,7 +1948,8 @@ export const honeypot = new Honeypot({
randomizeNameFieldName: false,
nameFieldName: "name__confirm",
validFromFieldName: "from__confirm", // null to disable it
encryptionSeed: undefined, // Ideally it should be unique even between processes
// HKDF SHA-256 key, a string with 256 bits (32 bytes) of entropy
encryptionSeed: "required in production",
});
```

Expand All @@ -1933,7 +1961,7 @@ import { honeypot } from "~/honeypot.server";

export async function loader() {
// more code here
return json({ honeypotInputProps: honeypot.getInputProps() });
return json({ honeypotInputProps: await honeypot.getInputProps() });
}
```

Expand Down Expand Up @@ -1981,7 +2009,7 @@ import { honeypot } from "~/honeypot.server";
export async function action({ request }) {
let formData = await request.formData();
try {
honeypot.check(formData);
await honeypot.check(formData);
} catch (error) {
if (error instanceof SpamError) {
// handle spam requests here
Expand Down
Binary file modified bun.lockb
Binary file not shown.
7 changes: 1 addition & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@
"@remix-run/node": "^2.0.0",
"@remix-run/react": "^2.0.0",
"@remix-run/router": "^1.7.2",
"crypto-js": "^4.1.1",
"intl-parse-accept-language": "^1.0.0",
"is-ip": "^5.0.1",
"react": "^18.0.0",
Expand All @@ -118,9 +117,6 @@
"@remix-run/router": {
"optional": true
},
"crypto-js": {
"optional": true
},
"intl-parse-accept-language": {
"optional": true
},
Expand All @@ -136,19 +132,18 @@
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.13.1",
"@peculiar/webcrypto": "^1.4.3",
"@remix-run/node": "^2.0.0",
"@remix-run/react": "^2.0.0",
"@remix-run/router": "^1.7.2",
"@remix-run/testing": "^2.0.0",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@types/crypto-js": "^4.1.2",
"@types/react": "^18.2.25",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-react": "^4.1.0",
"@vitest/coverage-v8": "^0.34.6",
"crypto-js": "^4.1.1",
"eslint": "^8.12.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.1",
Expand Down
48 changes: 48 additions & 0 deletions src/helpers/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const base64ToUrlSafeRegex = /[+/=]/g;
const urlSafeToBase64Regex = /[_-]/g;
const base64Replacements: { [key: string]: string } = {
"+": "-",
"/": "_",
"=": "",
"-": "+",
_: "/",
};

function transformBase64String(
base64String: string,
transformationType: "toUrlSafe" | "toRegular",
) {
let transformedString = base64String.replaceAll(
transformationType === "toUrlSafe"
? base64ToUrlSafeRegex
: urlSafeToBase64Regex,
(char) => base64Replacements[char],
);

if (transformationType === "toRegular") {
// Add padding with '=' if necessary for regular base64
while (transformedString.length % 4 !== 0) {
transformedString += "=";
}
}
return transformedString;
}

export function uint8ArrayToUrlSafeBase64(array: Uint8Array) {
let token = "";
for (let element of array) {
token += String.fromCodePoint(element);
}
return transformBase64String(globalThis.btoa(token), "toUrlSafe");
}

export function urlSafeBase64ToArrayBuffer(urlSafeBase64: string) {
let binaryString = globalThis.atob(
transformBase64String(urlSafeBase64, "toRegular"),
);
let bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.codePointAt(i)!;
}
return bytes.buffer;
}
57 changes: 43 additions & 14 deletions src/server/csrf.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Cookie } from "@remix-run/server-runtime";
import cryptoJS from "crypto-js";
import { getHeaders } from "./get-headers.js";
import { uint8ArrayToUrlSafeBase64 } from "../helpers/base64.js";

const textEncoder = new globalThis.TextEncoder();

export type CSRFErrorCode =
| "missing_token_in_cookie"
Expand Down Expand Up @@ -28,20 +30,27 @@ interface CSRFOptions {
*/
formDataKey?: string;
/**
* A secret to use for signing the CSRF token.
* HMAC SHA-256 secret key for signing the CSRF token, a string with 256 bits (32 bytes) of entropy
*/
secret?: string;
/**
* Optional WebCrypto polyfill for enviroments without native support.
*/
webCrypto?: Crypto;
}

export class CSRF {
private cookie: Cookie;
private formDataKey = "csrf";
private key?: CryptoKey;
private secret?: string;
private webCrypto: Crypto;

constructor(options: CSRFOptions) {
this.cookie = options.cookie;
this.formDataKey = options.formDataKey ?? "csrf";
this.secret = options.secret;
this.webCrypto = options.webCrypto ?? globalThis.crypto;
}

/**
Expand All @@ -50,12 +59,12 @@ export class CSRF {
* @param bytes The number of bytes used to generate the token
* @returns A random string in Base64URL
*/
generate(bytes = 32) {
let token = cryptoJS.lib.WordArray.random(bytes).toString(
cryptoJS.enc.Base64url,
async generate(bytes = 32) {
let token = uint8ArrayToUrlSafeBase64(
this.webCrypto.getRandomValues(new Uint8Array(bytes)),
);
if (!this.secret) return token;
let signature = this.sign(token);
let signature = await this.sign(token);
return [token, signature].join(".");
}

Expand All @@ -80,7 +89,9 @@ export class CSRF {
let headers = getHeaders(requestOrHeaders);
let existingToken = await this.cookie.parse(headers.get("cookie"));
let token =
typeof existingToken === "string" ? existingToken : this.generate(bytes);
typeof existingToken === "string"
? existingToken
: await this.generate(bytes);
let cookie = existingToken ? null : await this.cookie.serialize(token);
return [token, cookie] as const;
}
Expand Down Expand Up @@ -133,7 +144,7 @@ export class CSRF {
);
}

if (this.verifySignature(cookie) === false) {
if ((await this.verifySignature(cookie)) === false) {
throw new CSRFError(
"tampered_token_in_cookie",
"Tampered CSRF token in cookie.",
Expand Down Expand Up @@ -169,17 +180,35 @@ export class CSRF {
return this.cookie.parse(headers.get("cookie"));
}

private sign(token: string) {
private async getKey() {
if (!this.key) {
this.key = await this.webCrypto.subtle.importKey(
"raw",
textEncoder.encode(this.secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
}
return this.key;
}

private async sign(token: string) {
if (!this.secret) return token;
return cryptoJS
.HmacSHA256(token, this.secret)
.toString(cryptoJS.enc.Base64url);

let signature = await this.webCrypto.subtle.sign(
"HMAC",
await this.getKey(),
textEncoder.encode(token),
);

return uint8ArrayToUrlSafeBase64(new Uint8Array(signature));
}

private verifySignature(token: string) {
private async verifySignature(token: string) {
if (!this.secret) return true;
let [value, signature] = token.split(".");
let expectedSignature = this.sign(value);
let expectedSignature = await this.sign(value);
return signature === expectedSignature;
}
}
Loading

0 comments on commit bd53d17

Please sign in to comment.