Skip to content

Commit

Permalink
Refactor CSRF and Honeypot classes to use @oslojs/crypto for encrypti…
Browse files Browse the repository at this point in the history
…on, update tests and documentation accordingly
  • Loading branch information
sergiodxa committed Dec 4, 2024
1 parent cebc4a8 commit fca1b83
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 69 deletions.
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ npm install remix-utils
Additional optional dependencies may be needed, all optional dependencies are:

- `react-router`
- `crypto-js`
- `@oslojs/crypto`
- `@oslojs/encoding`
- `is-ip`
- `intl-parse-accept-language`
- `react`
Expand All @@ -22,7 +23,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 @oslojs/crypto @oslojs/encoding is-ip intl-parse-accept-language zod
```

React and React Router packages should be already installed in your project.
Expand Down Expand Up @@ -344,7 +345,7 @@ 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`, `@oslojs/crypto`, `@oslojs/encoding`, and React Router.
The CSRF related functions let you implement CSRF protection on your application.

Expand Down Expand Up @@ -1128,7 +1129,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
### Typed Cookies

> **Note**
> This depends on `zod`, and a Remix server runtime.
> This depends on `zod`, and React Router.
Cookie objects in Remix allows any type, the typed cookies from Remix Utils lets you use Zod to parse the cookie values and ensure they conform to a schema.

Expand Down Expand Up @@ -1223,7 +1224,7 @@ await typedCookie.serialize("some fake url to pass schema validation", {
### Typed Sessions

> **Note**
> This depends on `zod`, and a Remix server runtime.
> This depends on `zod`, and React Router.
Session objects in Remix allows any type, the typed sessions from Remix Utils lets you use Zod to parse the session data and ensure they conform to a schema.

Expand Down Expand Up @@ -1366,7 +1367,7 @@ This way, you can overwrite the map with a new one for a specific part of your a
### Rolling Cookies

> **Note**
> This depends on `zod`, and a Remix server runtime.
> This depends on `zod`, and React Router.
Rolling cookies allows you to prolong the expiration of a cookie by updating the expiration date of every cookie.

Expand Down Expand Up @@ -1858,7 +1859,7 @@ 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 `@oslojs/crypto`, and `@oslojs/encoding`.
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.

Expand Down
Binary file modified bun.lockb
Binary file not shown.
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,20 @@
"named action"
],
"peerDependencies": {
"react-router": "^7.0.0",
"crypto-js": "^4.1.1",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"intl-parse-accept-language": "^1.0.0",
"is-ip": "^5.0.1",
"react": "^18.0.0",
"react-router": "^7.0.0",
"zod": "^3.22.4"
},
"peerDependenciesMeta": {
"@oslojs/crypto": { "optional": true },
"@oslojs/encoding": { "optional": true },
"react-router": {
"optional": true
},
"crypto-js": {
"optional": true
},
"intl-parse-accept-language": {
"optional": true
},
Expand All @@ -119,15 +119,15 @@
"@arethetypeswrong/cli": "^0.17.0",
"@biomejs/biome": "^1.7.2",
"@happy-dom/global-registrator": "^15.11.7",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@total-typescript/tsconfig": "^1.0.4",
"@types/bun": "^1.1.14",
"@types/crypto-js": "^4.1.2",
"@types/react": "^18.2.78",
"@vitejs/plugin-react": "^4.3.4",
"crypto-js": "^4.1.1",
"intl-parse-accept-language": "^1.0.0",
"is-ip": "5.0.1",
"msw": "^2.6.6",
Expand Down
103 changes: 103 additions & 0 deletions src/common/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { type RandomReader, generateRandomString } from "@oslojs/crypto/random";

/**
* Uses Web Crypto API to encrypt a string using AES encryption.
*/
export async function encrypt(value: string, seed: string) {
let iv = generateIV();
let key = await deriveKeyForEncoding(seed);

let encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
stringToArrayBuffer(value),
);

// Combine the IV and the ciphertext for easier handling
let resultBuffer = new Uint8Array(iv.byteLength + encrypted.byteLength);
resultBuffer.set(iv);
resultBuffer.set(new Uint8Array(encrypted), iv.byteLength);

// Encode the result to Base64 for easier storage/transfer
return btoa(String.fromCharCode(...resultBuffer));
}

/**
* Uses Web Crypto API to decrypt a string using AES encryption.
*/
export async function decrypt(value: string, seed: string) {
// Decode the Base64 input
let encryptedBuffer = base64ToArrayBuffer(value);

// Extract the IV and ciphertext
let ivLength = 12; // 96-bit IV for AES-GCM
let iv = encryptedBuffer.slice(0, ivLength);
let ciphertext = encryptedBuffer.slice(ivLength);

let key = await deriveKeyForDecoding(seed);

// Decrypt the ciphertext
let decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
ciphertext,
);

// Convert the ArrayBuffer back to a string
return new TextDecoder().decode(decrypted);
}

export function randomString(bytes = 10) {
let random: RandomReader = {
read(bytes) {
crypto.getRandomValues(bytes);
},
};

/**
* List of characters in upper, lower, digits and special characters.
*/
let alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+";

return generateRandomString(random, alphabet, bytes);
}

// Convert a string to an ArrayBuffer
function stringToArrayBuffer(value: string) {
return new TextEncoder().encode(value);
}

// Derive a key from the seed using SHA-256
async function deriveKeyForEncoding(seed: string) {
let seedBuffer = stringToArrayBuffer(seed);
let hash = await crypto.subtle.digest("SHA-256", seedBuffer);
return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["encrypt"]);
}

async function deriveKeyForDecoding(seed: string) {
let seedBuffer = stringToArrayBuffer(seed);
let hash = await crypto.subtle.digest("SHA-256", seedBuffer);
return crypto.subtle.importKey(
"raw",
hash,
{ name: "AES-GCM" }, // Algorithm definition
false, // Key is not extractable
["encrypt", "decrypt"], // Allow both encryption and decryption
);
}

// Generate a random initialization vector
function generateIV() {
return crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV for AES-GCM
}

// Convert a Base64 string to an ArrayBuffer
function base64ToArrayBuffer(base64: string) {
let binaryString = atob(base64);
let buffer = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
buffer[i] = binaryString.charCodeAt(i);
}
return buffer;
}
10 changes: 5 additions & 5 deletions src/server/csrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ describe("CSRF", () => {
test("generates a new authenticity token with the default size", () => {
let token = csrf.generate();
expect(token).toStrictEqual(expect.any(String));
expect(token).toHaveLength(43);
expect(token).toHaveLength(32);
});

test("generates a new authenticity token with the given size", () => {
let token = csrf.generate(64);
expect(token).toStrictEqual(expect.any(String));
expect(token).toHaveLength(86);
expect(token).toHaveLength(64);
});

test("generates a new signed authenticity token", () => {
Expand All @@ -24,9 +24,9 @@ describe("CSRF", () => {
let token = csrf.generate();
let [value, signature] = token.split(".");

expect(token).toHaveLength(87);
expect(value).toHaveLength(43);
expect(signature).toHaveLength(43);
expect(token).toHaveLength(77);
expect(value).toHaveLength(32);
expect(signature).toHaveLength(44);
});

test("verify tokens using FormData and Headers", async () => {
Expand Down
12 changes: 5 additions & 7 deletions src/server/csrf.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import cryptoJS from "crypto-js";
import { sha256 } from "@oslojs/crypto/sha2";
import { encodeBase64url } from "@oslojs/encoding";
import type { Cookie } from "react-router";
import { randomString } from "../common/crypto.js";
import { getHeaders } from "./get-headers.js";

export type CSRFErrorCode =
Expand Down Expand Up @@ -51,9 +53,7 @@ export class CSRF {
* @returns A random string in Base64URL
*/
generate(bytes = 32) {
let token = cryptoJS.lib.WordArray.random(bytes).toString(
cryptoJS.enc.Base64url,
);
let token = randomString(bytes);
if (!this.secret) return token;
let signature = this.sign(token);
return [token, signature].join(".");
Expand Down Expand Up @@ -191,9 +191,7 @@ export class CSRF {

private sign(token: string) {
if (!this.secret) return token;
return cryptoJS
.HmacSHA256(token, this.secret)
.toString(cryptoJS.enc.Base64url);
return encodeBase64url(sha256(new TextEncoder().encode(token)));
}

private verifySignature(token: string) {
Expand Down
Loading

0 comments on commit fca1b83

Please sign in to comment.