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
1 change: 1 addition & 0 deletions packages/core/src/@types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export type AuthInternalErrorCode =
| "COOKIE_NOT_FOUND"
| "INVALID_ENVIRONMENT_CONFIGURATION"
| "INVALID_URL"
| "INVALID_SALT_SECRET_VALUE"

export type AuthSecurityErrorCode =
| "INVALID_STATE"
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/jose.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "dotenv/config"
import { createJWT, createJWS, createJWE, createDeriveKey } from "@aura-stack/jose"
import { createJWT, createJWS, createJWE, createDeriveKey, createSecret } from "@aura-stack/jose"
import { createDerivedSalt } from "@/secure.js"
import { AuthInternalError } from "@/errors.js"
export type { JWTPayload } from "@aura-stack/jose/jose"
Expand All @@ -23,7 +23,16 @@ export const createJoseInstance = (secret?: string) => {
)
}

const salt = env.AURA_AUTH_SALT ?? env.AUTH_SALT ?? createDerivedSalt(secret)
const salt = env.AURA_AUTH_SALT ?? env.AUTH_SALT ?? createDerivedSalt(secret) ?? "Not found"
try {
createSecret(salt)
} catch (error) {
throw new AuthInternalError(
"INVALID_SALT_SECRET_VALUE",
"AURA_AUTH_SALT environment variable is invalid. It must be at least 32 bits long.",
{ cause: error }
)
}
const { derivedKey: derivedSigningKey } = createDeriveKey(secret, salt, "signing")
const { derivedKey: derivedEncryptionKey } = createDeriveKey(secret, salt, "encryption")
const { derivedKey: derivedCsrfTokenKey } = createDeriveKey(secret, salt, "csrfToken")
Expand Down
1 change: 0 additions & 1 deletion packages/core/test/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export const {
} = createAuth({
oauth: [oauthCustomService, oauthCustomServiceProfile],
cookies: {},
secret: process.env.AURA_AUTH_SECRET,
logger: {
level: "debug",
log({ facility, severity, timestamp, message, structuredData, msgId }) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "path"
import { defineConfig } from "vitest/config"

const SECRET_KEY = crypto.randomBytes(32).toString("base64url")
const SALT_KEY = crypto.randomBytes(32).toString("base64url")

export default defineConfig({
test: {
Expand All @@ -16,6 +17,7 @@ export default defineConfig({
AURA_AUTH_SECRET: SECRET_KEY,
AURA_AUTH_GITHUB_CLIENT_ID: "github-client-id",
AURA_AUTH_GITHUB_CLIENT_SECRET: "github-client-secret",
AURA_AUTH_SALT: SALT_KEY,
},
},
resolve: {
Expand Down
2 changes: 2 additions & 0 deletions packages/jose/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Added entropy verification for secrets passed to functions for signing, encrypting and derivation keys. The `createSecret` function ensures that the passed secret is secure with at least 32 bytes and 4 bits of entropy per character.

- Added configuration options for signature verification and decryption via `verifyJWS` and `decryptJWE` functions. [#48](https://github.com/aura-stack-ts/auth/pull/48)

- Added Key derivation support to `createJWT`, `encodeJWT` and `decodeJWT` functions which allows to pass the separated keys for signing and encrypting the JWTs using the `jws` and `jwe` properties as argument in the functions. [#45](https://github.com/aura-stack-ts/auth/pull/45)
Expand Down
1 change: 1 addition & 0 deletions packages/jose/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { KeyObject } from "crypto"
export * from "@/sign.js"
export * from "@/encrypt.js"
export * from "@/deriveKey.js"
export * from "@/secret.js"

export type SecretInput = KeyObject | Uint8Array | string
export type DerivedKeyInput = { jws: SecretInput; jwe: SecretInput }
Expand Down
32 changes: 28 additions & 4 deletions packages/jose/src/secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,41 @@ import { InvalidSecretError } from "@/errors.js"
import { isObject } from "@/assert.js"
import type { DerivedKeyInput, SecretInput } from "@/index.js"

const MIN_SECRET_ENTROPY_BITS = 4

export const getEntropy = (secret: string): number => {
const charFreq = new Map<string, number>()
for (const char of secret) {
if (!charFreq.has(char)) {
charFreq.set(char, 0)
}
charFreq.set(char, charFreq.get(char)! + 1)
}
let entropy = 0
const length = secret.length
for (const freq of charFreq.values()) {
const p = freq / length
entropy -= p * Math.log2(p)
}
return entropy
}

/**
* Create a secret in Uint8Array format
*
* @param secret - The secret as a string or Uint8Array
* @returns The secret in Uint8Array format
*/
export const createSecret = (secret: SecretInput) => {
if (secret === undefined) throw new InvalidSecretError("Secret is required")
export const createSecret = (secret: SecretInput, length: number = 32) => {
if (!Boolean(secret)) throw new InvalidSecretError("Secret is required")
if (typeof secret === "string") {
if (new TextEncoder().encode(secret).byteLength < 32) {
throw new InvalidSecretError("Secret string must be at least 32 characters long")
const byteLength = new TextEncoder().encode(secret).byteLength
if (byteLength < length) {
throw new InvalidSecretError(`Secret string must be at least ${length} bytes long`)
}
const entropy = getEntropy(secret)
if (entropy < MIN_SECRET_ENTROPY_BITS) {
throw new InvalidSecretError("Secret string must have an entropy of at least 4 bits per character")
}
Comment thread
halvaradop marked this conversation as resolved.
return new Uint8Array(Buffer.from(secret, "utf-8"))
}
Expand Down
24 changes: 16 additions & 8 deletions packages/jose/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe("JWSs", () => {

test("fail JWT to try to verify an invalid JWS", async () => {
const { verifyJWS } = createJWS("my-secret-key")
await expect(verifyJWS("invalid.jwt.token")).rejects.toThrow("Secret string must be at least 32 characters long")
await expect(verifyJWS("invalid.jwt.token")).rejects.toThrow("Secret string must be at least 32 bytes long")
})

test("fail JWT to try to verify a JWS with invalid secret", async () => {
Expand All @@ -55,7 +55,7 @@ describe("JWSs", () => {
expect(jws).toBeDefined()

const { verifyJWS } = createJWS("wrong-secret-key")
await expect(verifyJWS(jws)).rejects.toThrow("Secret string must be at least 32 characters long")
await expect(verifyJWS(jws)).rejects.toThrow("Secret string must be at least 32 bytes long")
})

test("fail JWT with invalid format JWS", async () => {
Expand Down Expand Up @@ -185,7 +185,7 @@ describe("JWTs", () => {

test("createJWT with invalid secret", async () => {
const { encodeJWT } = createJWT("short")
await expect(encodeJWT(payload)).rejects.toThrow("Secret string must be at least 32 characters long")
await expect(encodeJWT(payload)).rejects.toThrow("Secret string must be at least 32 bytes long")
})

test("create a signed and encrypted JWT using createJWT with separate JWS and JWE secrets", async () => {
Expand All @@ -212,26 +212,34 @@ describe("createSecret", () => {

test("createSecret with string secret with at least 32 bytes", () => {
const secretString = "this-is-a-very-secure-and-long-secret"
const secret = createSecret(secretString)
expect(secret).toBeInstanceOf(Uint8Array)
expect(secretString).not.toBe(secret)
expect(() => createSecret(secretString)).toThrow("Secret string must have an entropy of at least 4 bits per character")
})
Comment thread
halvaradop marked this conversation as resolved.

test("createSecret with string secret with less than 32 bytes", () => {
const secretString = "short-secret"
expect(() => createSecret(secretString)).toThrow("Secret string must be at least 32 characters long")
expect(() => createSecret(secretString)).toThrow("Secret string must be at least 32 bytes long")
})

test("createSecret returns the passed Uint8Array secret", () => {
const secretArray = new Uint8Array(32)
const secret = createSecret(secretArray)
expect(secret).toBe(secretArray)
})

test("createSecret with null secret", () => {
const secret = null
expect(() => createSecret(secret as unknown as string)).toThrow("Secret is required")
})

test("createSecret with undefined secret", () => {
const secret = undefined
expect(() => createSecret(secret as unknown as string)).toThrow("Secret is required")
})
})

describe("createDeriveKey", () => {
test("createDeriveKey", () => {
expect(() => createDeriveKey("adfasdf")).toThrow(/Secret string must be at least 32 characters long/)
expect(() => createDeriveKey("adfasdf")).toThrow(/Secret string must be at least 32 bytes long/)
})

test("createDeriveKey with 32 bytes", () => {
Expand Down