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: 4 additions & 0 deletions packages/security/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
index.js
index.d.ts
lib/*.js
lib/*.d.ts
7 changes: 7 additions & 0 deletions packages/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ This option was renamed from `password` to `passwordHash`. Callers must update
both methods when upgrading. The token format is unchanged, so existing reset
tokens remain valid with the same stored hash, site secret, and expiry.

## Development

Source lives in `index.ts` and `lib/*.ts`. `pnpm build` emits CommonJS JavaScript
and declarations alongside the sources; only compiled files are published.

Run `pnpm test` for the build, type checks, runtime tests, coverage, and lint.

# Copyright & License

Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE).
25 changes: 0 additions & 25 deletions packages/security/index.js

This file was deleted.

25 changes: 25 additions & 0 deletions packages/security/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export = {
get url(): typeof import('./lib/url') {
return require('./lib/url');
},

get tokens(): typeof import('./lib/tokens') {
return require('./lib/tokens');
},

get string(): typeof import('./lib/string') {
return require('./lib/string');
},

get identifier(): typeof import('./lib/identifier') {
return require('./lib/identifier');
},

get password(): typeof import('./lib/password') {
return require('./lib/password');
},

get secret(): typeof import('./lib/secret') {
return require('./lib/secret');
},
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
let _private = {};

_private.getRandomInt = function (min, max) {
function getRandomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
}

/**
* Return a unique identifier with the given `len`.
Expand All @@ -11,15 +9,15 @@ _private.getRandomInt = function (min, max) {
* @param {Number} maxLength
* @return {String}
*/
module.exports.uid = function uid(maxLength) {
const buf = [];
export function uid(maxLength: number): string {
const buf: string[] = [];
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charLength = chars.length;
let i;

for (i = 0; i < maxLength; i = i + 1) {
buf.push(chars[_private.getRandomInt(0, charLength - 1)]);
buf.push(chars[getRandomInt(0, charLength - 1)]);
}

return buf.join('');
};
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
const bcrypt = require('bcryptjs');
import bcrypt from 'bcryptjs';

let HASH_ROUNDS = 10;

if (process.env.NODE_ENV?.startsWith('testing')) {
HASH_ROUNDS = 1;
}

module.exports.hash = async function hash(plainPassword) {
export async function hash(plainPassword: string): Promise<string> {
const salt = await bcrypt.genSalt(HASH_ROUNDS);
return bcrypt.hash(plainPassword, salt);
};
}

module.exports.compare = function compare(plainPassword, hashedPassword) {
export function compare(plainPassword: string, hashedPassword: string): Promise<boolean> {
return bcrypt.compare(plainPassword, hashedPassword);
};
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const crypto = require('crypto');
import { randomBytes } from 'node:crypto';

/*
* Uses birthday problem estimation to calculate chance of collision
Expand All @@ -21,11 +21,11 @@ const crypto = require('crypto');
* @param {String|Number} [typeOrLength=64]
* @returns
*/
module.exports.create = (typeOrLength) => {
export function create(typeOrLength?: string | number): string {
let bytes;
let length;

if (Number.isInteger(typeOrLength)) {
if (typeof typeOrLength === 'number' && Number.isInteger(typeOrLength)) {
bytes = Math.ceil(typeOrLength / 2);
length = typeOrLength;
} else if (typeOrLength === 'content') {
Expand All @@ -36,5 +36,5 @@ module.exports.create = (typeOrLength) => {
length = 64;
}

return crypto.randomBytes(bytes).toString('hex').slice(0, length);
};
return randomBytes(bytes).toString('hex').slice(0, length);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const slugify = require('@tryghost/string').slugify;
import { slugify } from '@tryghost/string';

/**
* @param {string} string
* @param {{importing?: boolean}} [options]
*/
module.exports.safe = function safe(string, options = {}) {
export function safe(string?: string | null, options: { importing?: boolean } = {}): string {
return slugify(string, { requiredChangesOnly: options.importing === true });
};
}
86 changes: 47 additions & 39 deletions packages/security/lib/tokens.js → packages/security/lib/tokens.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
const crypto = require('crypto');

module.exports.generateFromContent = function generateFromContent(options) {
options = options || {};
import { createHash, type BinaryLike } from 'node:crypto';

type ContentTokenOptions = { content: BinaryLike };
type EmailTokenOptions = { expires: number | string; email: string; secret: string };
type ResetTokenOptions = {
expires: number | string;
email: string;
dbHash: string;
passwordHash: BinaryLike;
};
type ExtractedToken = { expires: number; email: string };
type TokenComparison = {
correct: boolean;
reason?: 'invalid_expiry' | 'expired' | 'invalid';
};

const hash = crypto.createHash('sha256');
const content = options.content;
export function generateFromContent(options: ContentTokenOptions): string {
const hash = createHash('sha256');
const content = options?.content;

let text = '';

hash.update(content);

text += [content, hash.digest('base64')].join('|');
return Buffer.from(text).toString('base64');
};

module.exports.generateFromEmail = function generateFromEmail(options) {
options = options || {};
}

const hash = crypto.createHash('sha256');
const expires = options.expires;
const email = options.email;
const secret = options.secret;
export function generateFromEmail(options: EmailTokenOptions): string {
const hash = createHash('sha256');
const expires = options?.expires;
const email = options?.email;
const secret = options?.secret;

let text = '';

Expand All @@ -30,17 +40,15 @@ module.exports.generateFromEmail = function generateFromEmail(options) {

text += [expires, email, hash.digest('base64')].join('|');
return Buffer.from(text).toString('base64');
};

module.exports.resetToken = {
generateHash: function generateHash(options) {
options = options || {};

const hash = crypto.createHash('sha256');
const expires = options.expires;
const email = options.email;
const dbHash = options.dbHash;
const passwordHash = options.passwordHash;
}

export const resetToken = {
generateHash: function generateHash(options: ResetTokenOptions): string {
const hash = createHash('sha256');
const expires = options?.expires;
const email = options?.email;
const dbHash = options?.dbHash;
const passwordHash = options?.passwordHash;
let text = '';

hash.update(String(expires));
Expand All @@ -53,10 +61,8 @@ module.exports.resetToken = {
text += [expires, email, hash.digest('base64')].join('|');
return Buffer.from(text).toString('base64');
},
extract: function extract(options) {
options = options || {};

const token = options.token;
extract: function extract(options: { token: string }): ExtractedToken | false {
const token = options?.token;
const tokenText = Buffer.from(token, 'base64').toString('ascii');
let parts;
let expires;
Expand All @@ -77,18 +83,20 @@ module.exports.resetToken = {
email: email,
};
},
compare: function compare(options) {
options = options || {};

const tokenToCompare = options.token;
const parts = exports.resetToken.extract({ token: tokenToCompare });
const dbHash = options.dbHash;
const passwordHash = options.passwordHash;
compare: function compare(options: {
token: string;
dbHash: string;
passwordHash: BinaryLike;
}): TokenComparison {
const tokenToCompare = options?.token;
const parts = resetToken.extract({ token: tokenToCompare });
const dbHash = options?.dbHash;
const passwordHash = options?.passwordHash;
let generatedToken;
let diff = 0;
let i;

if (isNaN(parts.expires)) {
if (!parts || isNaN(parts.expires)) {
return {
correct: false,
reason: 'invalid_expiry',
Expand All @@ -103,7 +111,7 @@ module.exports.resetToken = {
};
}

generatedToken = exports.resetToken.generateHash({
generatedToken = resetToken.generateHash({
email: parts.email,
expires: parts.expires,
dbHash: dbHash,
Expand All @@ -118,7 +126,7 @@ module.exports.resetToken = {
diff |= tokenToCompare.charCodeAt(i) ^ generatedToken.charCodeAt(i);
}

const result = {
const result: TokenComparison = {
correct: diff === 0,
};

Expand Down
8 changes: 4 additions & 4 deletions packages/security/lib/url.js → packages/security/lib/url.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// The token is encoded URL safe by replacing '+' with '-', '\' with '_' and removing '='
// NOTE: the token is not encoded using valid base64 anymore
module.exports.encodeBase64 = function encodeBase64(base64String) {
export function encodeBase64(base64String: string): string {
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
}

// Decode url safe base64 encoding and add padding ('=')
module.exports.decodeBase64 = function decodeBase64(base64String) {
export function decodeBase64(base64String: string): string {
base64String = base64String.replace(/-/g, '+').replace(/_/g, '/');
while (base64String.length % 4) {
base64String += '=';
}
return base64String;
};
}
20 changes: 16 additions & 4 deletions packages/security/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,31 @@
},
"files": [
"index.js",
"lib"
"index.d.ts",
"lib/*.js",
"lib/*.d.ts"
],
"main": "index.js",
"types": "index.d.ts",
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing vitest run --config ../../vitest.config.ts --root . --coverage",
"lint": "oxlint -c ../../.oxlintrc.json ."
"dev": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"prepare": "tsc",
"pretest": "pnpm run build",
"test": "pnpm run test:types && NODE_ENV=testing vitest run --coverage",
"test:types": "tsc -p tsconfig.test.json --noEmit",
"lint": "oxlint -c ../../.oxlintrc.json .",
"posttest": "pnpm run lint"
},
"dependencies": {
"@tryghost/string": "^0.3.5",
"bcryptjs": "^3.0.3"
},
"devDependencies": {
"@types/node": "24.19.2",
"typescript": "catalog:"
}
}
24 changes: 24 additions & 0 deletions packages/security/test/commonjs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const { execFileSync } = require('node:child_process');

describe('Security CommonJS compatibility', function () {
it('keeps lazy root getters and legacy lib entry points', function () {
execFileSync(process.execPath, [
'-e',
`
const assert = require('node:assert/strict');
const path = require('node:path');
const entry = process.argv[1];
const security = require(entry);
const names = ['url', 'tokens', 'string', 'identifier', 'password', 'secret'];
assert.deepEqual(Object.keys(security), names);
for (const name of names) {
const file = require.resolve(path.join(path.dirname(entry), 'lib', name));
assert.equal(require.cache[file], undefined);
assert.equal(typeof Object.getOwnPropertyDescriptor(security, name).get, 'function');
assert.equal(security[name], require(file));
}
`,
require.resolve('../'),
]);
});
});
5 changes: 5 additions & 0 deletions packages/security/test/secret.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ describe('Lib: Security - Secret', function () {
assert.match(secret, /[0-9a-z]+/);
});

it('uses the default length for numeric strings and non-integer numbers', function () {
assert.equal(security.secret.create('15').length, 64);
assert.equal(security.secret.create(15.5).length, 64);
});

it('generates a 32 byte secret by default', function () {
let secret = security.secret.create();
assert.equal(typeof secret, 'string');
Expand Down
Loading
Loading