diff --git a/README.md b/README.md
index 4e02b8f..dcd0f84 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,8 @@ JSON
## Usage
See ./examples folder.
+### Node.js
+
Setting up phpwd
``` javascript
const { createPhpwd } = require('../lib')
@@ -212,6 +214,32 @@ const run = async () => {
run().catch(console.error)
```
+### Browser
+
+A browser-ready bundle is available at `browser/phpwd.js` and is also exposed through the package `browser`, `unpkg`, and `jsdelivr` fields. It uses the Web Crypto API and does not require Node.js `crypto`, `Buffer`, or `assert` modules.
+
+``` html
+
+
+```
+
+The browser build keeps the same high-level API (`createPhpwd`, `generateSalt`, `generate`, `validate`, `validateInitial`, `getInitialData`, `getAuthData`, and `parse`). Web Crypto supports SHA-1, SHA-256, SHA-384, and SHA-512 HMAC algorithms in browsers; the default `sha256` works in both Node.js and browsers.
+
Validate
``` javascript
const { createPhpwd } = require('../lib')
diff --git a/browser/phpwd.js b/browser/phpwd.js
new file mode 100644
index 0000000..9c13043
--- /dev/null
+++ b/browser/phpwd.js
@@ -0,0 +1,275 @@
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory()
+ else root.phpwd = factory()
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict'
+
+ const assert = (condition, message) => {
+ if (!condition) throw new Error(message || 'Assertion failed.')
+ }
+
+ const checkEncode = enc => (
+ assert(enc === 'base64' || enc === 'hex', 'Invalid encode.')
+ )
+
+ const getCrypto = () => {
+ const crypto = globalThis.crypto || globalThis.msCrypto
+ assert(crypto && crypto.subtle, 'Web Crypto API is required.')
+ return crypto
+ }
+
+ const getRandomValues = bytes => {
+ const crypto = getCrypto()
+ const salt = new Uint8Array(bytes)
+ crypto.getRandomValues(salt)
+ return salt
+ }
+
+ const toUint8 = input => {
+ if (input instanceof Uint8Array) return input
+ if (input instanceof ArrayBuffer) return new Uint8Array(input)
+ if (ArrayBuffer.isView(input)) {
+ return new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
+ }
+ return new Uint8Array(input)
+ }
+
+ const utf8ToBytes = str => new TextEncoder().encode(str)
+ const bytesToUtf8 = bytes => new TextDecoder().decode(bytes)
+
+ const bytesToHex = bytes => Array.from(toUint8(bytes), byte => (
+ byte.toString(16).padStart(2, '0')
+ )).join('')
+
+ const hexToBytes = str => {
+ assert(str.length % 2 === 0, 'Invalid hex string.')
+ const bytes = new Uint8Array(str.length / 2)
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = parseInt(str.slice(i * 2, i * 2 + 2), 16)
+ }
+ return bytes
+ }
+
+ const bytesToBase64 = bytes => {
+ bytes = toUint8(bytes)
+ let binary = ''
+ const chunk = 0x8000
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk))
+ }
+ return btoa(binary)
+ }
+
+ const base64ToBytes = str => {
+ const binary = atob(str)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
+ return bytes
+ }
+
+ const encodeBytes = (bytes, enc) => {
+ checkEncode(enc)
+ return enc === 'hex' ? bytesToHex(bytes) : bytesToBase64(bytes)
+ }
+
+ const decodeBytes = (str, enc) => {
+ checkEncode(enc)
+ return enc === 'hex' ? hexToBytes(str) : base64ToBytes(str)
+ }
+
+ const compare = (left, right) => {
+ left = toUint8(left)
+ right = toUint8(right)
+ if (left.length !== right.length) return left.length - right.length
+ for (let i = 0; i < left.length; i++) {
+ if (left[i] !== right[i]) return left[i] - right[i]
+ }
+ return 0
+ }
+
+ const webCryptoAlg = alg => {
+ const normalized = String(alg).toLowerCase().replace(/[-_]/g, '')
+ const names = {
+ sha1: 'SHA-1',
+ sha256: 'SHA-256',
+ sha384: 'SHA-384',
+ sha512: 'SHA-512'
+ }
+ const name = names[normalized]
+ assert(name, `Unsupported browser hash algorithm: ${alg}.`)
+ return name
+ }
+
+ /**
+ * Calc HMAC for a single round.
+ * Browser version uses Web Crypto and supports SHA-1/SHA-256/SHA-384/SHA-512.
+ */
+ const hmac = async (alg, key, input) => {
+ const crypto = getCrypto()
+ const cryptoKey = await crypto.subtle.importKey(
+ 'raw', toUint8(key), { name: 'HMAC', hash: { name: webCryptoAlg(alg) } },
+ false, ['sign']
+ )
+ const signed = await crypto.subtle.sign('HMAC', cryptoKey, toUint8(input))
+ return new Uint8Array(signed)
+ }
+
+ /**
+ * Unblocking phpwd derivation for browsers.
+ */
+ const derive = async (alg, salt, input, iterations) => {
+ const crypto = getCrypto()
+ const cryptoKey = await crypto.subtle.importKey(
+ 'raw', toUint8(salt), { name: 'HMAC', hash: { name: webCryptoAlg(alg) } },
+ false, ['sign']
+ )
+ input = toUint8(input)
+ while (iterations-- > 0) {
+ input = new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, input))
+ }
+ return input
+ }
+
+ const parsePhpwdJson = (jsonStr, enc) => {
+ checkEncode(enc)
+ const phpwd = typeof jsonStr === 'string' ? JSON.parse(jsonStr) : jsonStr
+ const { salt, hash } = phpwd
+ return Object.assign({}, phpwd, {
+ salt: typeof salt === 'string' ? decodeBytes(salt, enc) : toUint8(salt),
+ hash: typeof hash === 'string' ? decodeBytes(hash, enc) : toUint8(hash)
+ })
+ }
+
+ const serializePhpwdJson = (phpwd, enc) => {
+ checkEncode(enc)
+ return JSON.stringify({
+ hash: encodeBytes(phpwd.hash, enc),
+ salt: encodeBytes(typeof phpwd.salt === 'string' ? decodeBytes(phpwd.salt, enc) : phpwd.salt, enc),
+ index: phpwd.index,
+ alg: phpwd.alg
+ })
+ }
+
+ const parsePhpwdString = (str, enc) => {
+ checkEncode(enc)
+ const [ header, salt, hash ] = str.split('.')
+ assert(header && salt && hash, 'Invalid phpwd token.')
+ const phpwd = JSON.parse(bytesToUtf8(decodeBytes(header, enc)))
+ return Object.assign(phpwd, {
+ salt: decodeBytes(salt, enc),
+ hash: decodeBytes(hash, enc)
+ })
+ }
+
+ const serializePhpwdString = (phpwd, enc) => {
+ checkEncode(enc)
+ const header = encodeBytes(utf8ToBytes(JSON.stringify({
+ index: phpwd.index,
+ alg: phpwd.alg
+ })), enc)
+ const salt = encodeBytes(typeof phpwd.salt === 'string' ? decodeBytes(phpwd.salt, enc) : phpwd.salt, enc)
+ const hash = encodeBytes(phpwd.hash, enc)
+ return `${header}.${salt}.${hash}`
+ }
+
+ const isJson = str => typeof str === 'string' && str.startsWith('{')
+
+ const parse = (phpwd, enc) => {
+ if (typeof phpwd === 'object') return parsePhpwdJson(phpwd, enc)
+ return isJson(phpwd) ? parsePhpwdJson(phpwd, enc) : parsePhpwdString(phpwd, enc)
+ }
+
+ const getSalt = async bytes => getRandomValues(bytes)
+
+ class Phpwd {
+ constructor(options) {
+ options = options || {}
+ const { alg, minIndex, maxIndex, updateIndex,
+ saltSize, minDecrement, encode } = options
+ this.alg = alg || 'sha256'
+ this.minIndex = minIndex || 20000
+ this.maxIndex = maxIndex || 200000
+ this.updateIndex = updateIndex || 50000
+ this.saltSize = saltSize || 32
+ this.minDecrement = minDecrement || 1
+ this.encode = encode || 'base64'
+ assert(this.encode === 'base64' || this.encode === 'hex', 'Unsupported encode.')
+ webCryptoAlg(this.alg)
+ }
+
+ async generateSalt() {
+ const salt = await getSalt(this.saltSize)
+ return encodeBytes(salt, this.encode)
+ }
+
+ parse(input) {
+ const { alg, saltSize, maxIndex, minIndex, encode } = this
+ const phpwd = parse(input, encode)
+ const { index, salt, hash } = phpwd
+ assert(hash, 'Hash is required.')
+ assert(phpwd.alg === alg, 'Invalid hash algorithm.')
+ assert(index <= maxIndex && index > minIndex, 'Invalid index.')
+ assert(salt.length === saltSize, 'Invalid salt size.')
+ return phpwd
+ }
+
+ validateInitial(input) {
+ const { index } = this.parse(input)
+ assert(index === this.maxIndex, 'Initial index should be same as maxIndex.')
+ return true
+ }
+
+ async getInitialData() {
+ const { minIndex, maxIndex, minDecrement, updateIndex } = this
+ const salt = await this.generateSalt()
+ return { minIndex, maxIndex, updateIndex, minDecrement, salt }
+ }
+
+ async getAuthData(input) {
+ const { updateIndex, minIndex, maxIndex, minDecrement, encode } = this
+ const { index, salt } = this.parse(input)
+ const data = { index, salt: encodeBytes(salt, encode),
+ minIndex, maxIndex, minDecrement }
+ if (index <= updateIndex) {
+ const saltUpdate = await this.generateSalt()
+ const indexUpdate = maxIndex
+ Object.assign(data, { saltUpdate, indexUpdate })
+ }
+ return data
+ }
+
+ async generate(payload) {
+ const { encode, alg } = this
+ const { pass, index, salt, json } = payload
+ const saltBytes = typeof salt === 'string' ? decodeBytes(salt, encode) : toUint8(salt)
+ const passBytes = typeof pass === 'string' ? utf8ToBytes(pass) : toUint8(pass)
+ const hash = await derive(alg, saltBytes, passBytes, index)
+ const phpwd = { hash, salt: saltBytes, index, alg }
+ return json ? serializePhpwdJson(phpwd, encode) : serializePhpwdString(phpwd, encode)
+ }
+
+ async validate(input, target) {
+ const { alg, minDecrement } = this
+ const iPhpwd = this.parse(input)
+ const tPhpwd = this.parse(target)
+ const { salt, index, hash } = tPhpwd
+ assert(compare(salt, iPhpwd.salt) === 0, 'Invalid salt value.')
+ assert(index - iPhpwd.index >= minDecrement, 'Reject by index.')
+ const iterations = tPhpwd.index - iPhpwd.index
+ const derived = await derive(alg, salt, iPhpwd.hash, iterations)
+ assert(compare(hash, derived) === 0, 'Invalid phpwd.')
+ return true
+ }
+ }
+
+ const createPhpwd = options => new Phpwd(options)
+
+ return {
+ utils: { getSalt, parsePhpwdJson, serializePhpwdJson,
+ parsePhpwdString, serializePhpwdString, parse },
+ derive,
+ hmac,
+ Phpwd,
+ createPhpwd
+ }
+})
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..034a63a
--- /dev/null
+++ b/index.js
@@ -0,0 +1,2 @@
+'use strict'
+module.exports = require('./lib')
diff --git a/package-lock.json b/package-lock.json
index daf1f1b..daa33dd 100755
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "phpwd",
- "version": "0.0.1",
+ "version": "0.1.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "phpwd",
- "version": "0.0.1",
+ "version": "0.1.1",
"license": "MIT",
"devDependencies": {
"metatests": "^0.8.2"
diff --git a/package.json b/package.json
index e6a2d94..5c57d9e 100755
--- a/package.json
+++ b/package.json
@@ -2,14 +2,15 @@
"name": "phpwd",
"version": "0.1.1",
"description": "Partial hashed password protocol",
- "main": "index.js",
+ "main": "lib/index.js",
"directories": {
"example": "examples",
"lib": "lib",
"test": "test"
},
"scripts": {
- "test": "metatests test/"
+ "test": "metatests test/",
+ "test:browser": "node test/browser.js"
},
"keywords": [
"phpwd",
@@ -21,5 +22,15 @@
"license": "MIT",
"devDependencies": {
"metatests": "^0.8.2"
- }
+ },
+ "browser": "browser/phpwd.js",
+ "unpkg": "browser/phpwd.js",
+ "jsdelivr": "browser/phpwd.js",
+ "files": [
+ "lib",
+ "browser",
+ "index.js",
+ "README.md",
+ "LICENSE"
+ ]
}
diff --git a/test/browser.js b/test/browser.js
new file mode 100644
index 0000000..0326c92
--- /dev/null
+++ b/test/browser.js
@@ -0,0 +1,26 @@
+'use strict'
+const assert = require('node:assert/strict')
+const { createPhpwd } = require('../browser/phpwd')
+
+const run = async () => {
+ const phpwd = createPhpwd({ minIndex: 10, maxIndex: 50, updateIndex: 20 })
+ const salt = await phpwd.generateSalt()
+ const target = await phpwd.generate({ salt, pass: 'password', index: 50 })
+ const input = await phpwd.generate({ salt, pass: 'password', index: 49 })
+ const json = await phpwd.generate({ salt, pass: 'password', index: 50, json: true })
+ const authData = await phpwd.getAuthData(input)
+ assert.equal(await phpwd.validate(input, target), true)
+ assert.equal(phpwd.validateInitial(json), true)
+ assert.equal(phpwd.parse(target).salt.length, 32)
+ assert.equal(authData.salt, salt)
+ assert.equal(typeof createPhpwd, 'function')
+}
+
+if (require.main === module) {
+ run().catch(error => {
+ console.error(error)
+ process.exitCode = 1
+ })
+}
+
+module.exports = { run }