From c04ac8fd13508f7ca3fd52c714fa5f478ff5071a Mon Sep 17 00:00:00 2001 From: Kevin Fitzgerald Date: Thu, 18 Jan 2024 19:29:37 -0600 Subject: [PATCH] Added base62p for leading zero byte preservation - node.js.yml: Dropped testing support for v14 and v16, added v18 and v20 - base62p.js: Added - test/base58.js: Added test proving leading zeros are preserved - test/base62.js: Added test showing that leading zeros are not preserved - base62p.js: Added, with tests showing that leading zeros are preserved - index.js: Export base62p - package.json: Bump to v3.3.0 - README.md: Updated docs for base62p Closes #16 - Thanks @Ronan-H ! --- .github/workflows/node.js.yml | 2 +- README.md | 35 ++++++- index.js | 2 + lib/base62p.js | 76 ++++++++++++++++ package.json | 2 +- test/base58.js | 7 ++ test/base62.js | 7 ++ test/base62p.js | 166 ++++++++++++++++++++++++++++++++++ 8 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 lib/base62p.js create mode 100644 test/base62p.js diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 9b91709..d4d169e 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [14.x, 16.x] + node-version: [18.x, 20.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ # Cannot test on 10.x because the mongo driver only supports v12 and higher diff --git a/README.md b/README.md index dcbc10a..de30207 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,14 @@ npm install base-id ```js -const { base58, base62 } = require('base-id'); +const { base58, base62, base62p } = require('base-id'); // Generate a new crypto-random id with an arbitrary prefix base58.generateToken(24, 'account_'); // account_ifq8PeVV9J3weEtz5V14cr9H7AuKhndD // Generate a new crypto-random id with an arbitrary prefix base62.generateToken(24, 'product_'); // product_8egyAcmiJhK0pFThcYHYIojG9GIKK7A4 +base62p.generateToken(24, 'product_'); // product_mKO7RdTgKjHQtkrRMhm6uQAWmJ0hCRaG @@ -34,6 +35,7 @@ let res = base58.encode(hex); // 2i6ye84HA6z; // Encode a hex-string to base62 res = base62.encode(hex); // SnmsvJ1ziv; +res = base62p.encode(hex); // mR3E2wPbQQL; @@ -46,7 +48,7 @@ new ObjectId(base58.decodeWithPrefix('charge_2d2yysrPLNBLYpWfK', 'charge_')); // ``` -## `base58` and `base62` +## `base58`, `base62`, and `base62p` The module exports both base58 and base62 instances with the following members. @@ -64,13 +66,38 @@ The module exports both base58 and base62 instances with the following members. * `encodeNumericToHex(dec)` – Encode a number to hex string * `getHexFromObject(mixed)` Gets the hex string from the given thing. Accepts a hex string, number, BigInt, byte array, or MongoDB `ObjectId` instance. * `getUUIDFromHex(hex, lowercase=true)` Gets the formatted UUID string from the given hex string. Defaults to lowercase. - + +## Base62 vs Base62p + +There are two versions of base62 included, base62 and base62p, with the difference being that base62 **does not preserve** +leading zero bytes while **base62p does preserve** leading zero bytes by prepending a magic `0x01` byte before encoding. + +> **Note: Base62 and Base62p are not interchangeable**. Please use the version that suits your application. +> In a future version, base62p may become the default pending community feedback. + +It's also worth noting that base58 preserves leading zero bytes. + +> **For new applications using base62, please consider using base62p.** + +```js + +const { base58, base62, base62p } = require('base-id'); + +// example 32 byte uuid with all zeros +base58.decode(base58.encode('00000000000000000000000000000000')); // 00000000000000000000000000000000 +base62.decode(base62.encode('00000000000000000000000000000000')); // 00 +base62p.decode(base62p.encode('00000000000000000000000000000000')); // 00000000000000000000000000000000 + +``` + ## Breaking Changes +> In a future major release version, base62 may be replaced with base62p so base62 and base58 by default preserve leading zero bytes. + ### v3.0.0 * Removed BigNum dependency (uses JS2020's built-in BigInt) ### v2.0.0 * Removed `binaryToHex` - * Removed `hexToBinary` \ No newline at end of file + * Removed `hexToBinary` \ No newline at end of file diff --git a/index.js b/index.js index 0240603..1c993a1 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,8 @@ const Base58 = require('./lib/base58'); const Base62 = require('./lib/base62'); +const Base62p = require('./lib/base62p'); exports.base58 = new Base58(); exports.base62 = new Base62(); +exports.base62p = new Base62p(); diff --git a/lib/base62p.js b/lib/base62p.js new file mode 100644 index 0000000..c0be4e5 --- /dev/null +++ b/lib/base62p.js @@ -0,0 +1,76 @@ +"use strict"; + +const Base62 = require('./base62'); + +/** + * Base62 but prepends a magic byte to preserve leading zero bytes + */ +class Base62p extends Base62 { + + constructor() { + super(); + } + + /** + * Encode a hex string or array of bytes + * @param mixed – Hex string or array of bytes + * @returns {string|null} - Base encoded string + */ + encode(mixed) { + let hex = this.getHexFromObject(mixed); + if (!hex) return null; + + hex = '01' + hex; // prepend magic byte + hex = this.decodeHexToNumeric(hex); + let output = ""; + let rem; + while (hex > 0n) { + rem = hex % 62n; + hex = hex / 62n; + output = Base62p.BASE62_CHARS[rem] + output; + } + + return output; + } + + + /** + * Decode a base-encoded string into a hex string + * @param {string} base62 - Base encoded string + * @returns {string|null} – hex encoded string + */ + decode(base62) { + + // only valid chars allowed + if (base62.match(/[^0-9A-Za-z]/) != null) { + return null; + } + + let output = BigInt(0); + let current; + for (let i = 0; i < base62.length; i++) { + current = BigInt(Base62p.BASE62_CHARS.indexOf(base62[i])); + output = output * 62n + current; + } + + let outputHex = this.encodeNumericToHex(output); + + /* istanbul ignore else: shouldn't happen unless garbage input given */ + if (outputHex.length % 2 !== 0) { + outputHex = "0" + outputHex; + } + + // magic byte required or nothing will return + if (outputHex.substring(0, 2) !== '01') { + return null; + } + + // strip magic byte + outputHex = outputHex.substring(2); + + //noinspection JSUnresolvedFunction + return outputHex.toUpperCase(); + } +} + +module.exports = Base62p; \ No newline at end of file diff --git a/package.json b/package.json index ec162e4..e3efa9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "base-id", - "version": "3.2.0", + "version": "3.3.0", "description": "Encode, decode, and generate base-58 and base-62 identifiers. Convertible MongoDB ObjectIds!", "main": "index.js", "scripts": { diff --git a/test/base58.js b/test/base58.js index 4c81375..fd7a488 100644 --- a/test/base58.js +++ b/test/base58.js @@ -156,4 +156,11 @@ describe('Base58', () => { res.should.be.instanceOf(Uint8Array); }); + it('should encode/decode a uuid with two leading zeroes', async () => { + should(Base.base58.decode(Base.base58.encode('0039d4cd-3923-44ed-a2d5-450776bdfce9'))).be.exactly('0039D4CD392344EDA2D5450776BDFCE9'); + should(Base.base58.decode(Base.base58.encode('0039D4CD392344EDA2D5450776BDFCE9'))).be.exactly('0039D4CD392344EDA2D5450776BDFCE9'); + should(Base.base58.decode(Base.base58.encode('00000000000000000000000000000000'))).be.exactly('00000000000000000000000000000000'); + should(Base.base58.decode(Base.base58.encode('00000000000000000000000000000000'))).be.exactly('00000000000000000000000000000000'); + }); + }); \ No newline at end of file diff --git a/test/base62.js b/test/base62.js index 0e71c2d..032f3a0 100644 --- a/test/base62.js +++ b/test/base62.js @@ -153,5 +153,12 @@ describe('Base62', () => { res.should.be.instanceOf(Uint8Array); }); + it('leading zeros are not preserved in base62', async () => { + should(Base.base62.decode(Base.base62.encode('00'))).be.exactly('00'); + should(Base.base62.decode(Base.base62.encode('0039d4cd-3923-44ed-a2d5-450776bdfce9'))).be.exactly('39D4CD392344EDA2D5450776BDFCE9'); + should(Base.base62.decode(Base.base62.encode('0039D4CD392344EDA2D5450776BDFCE9'))).be.exactly('39D4CD392344EDA2D5450776BDFCE9'); + should(Base.base62.decode(Base.base62.encode('00000000000000000000000000000000'))).be.exactly('00'); + }); + }); \ No newline at end of file diff --git a/test/base62p.js b/test/base62p.js new file mode 100644 index 0000000..c5d0371 --- /dev/null +++ b/test/base62p.js @@ -0,0 +1,166 @@ +"use strict"; + +const Base = require('../index'); +const should = require('should'); +const Crypto = require("crypto"); +const ObjectId = require('mongodb').ObjectId; + +describe('Base62', () => { + + it('should return null when crap is given', () => { + + should(Base.base62p.encode()).not.be.ok(); + should(Base.base62p.encode(null)).not.be.ok(); + should(Base.base62p.encode(undefined)).not.be.ok(); + should(Base.base62p.encode(() => {})).not.be.ok(); + should(Base.base62p.encode({})).not.be.ok(); + should(Base.base62p.encode([])).not.be.ok(); + }); + + it('should return null when crap is given with prefix', () => { + should(Base.base62p.encodeWithPrefix()).not.be.ok(); + should(Base.base62p.encodeWithPrefix(null, 'prefix_')).not.be.ok(); + should(Base.base62p.encodeWithPrefix(undefined, 'prefix_')).not.be.ok(); + should(Base.base62p.encodeWithPrefix(() => {}, 'prefix_')).not.be.ok(); + should(Base.base62p.encodeWithPrefix({}, 'prefix_')).not.be.ok(); + should(Base.base62p.encodeWithPrefix([], 'prefix_')).not.be.ok(); + + should(Base.base62p.encodeWithPrefix(0, 'prefix_')).equal('prefix_48'); + }); + + it('should return null when decoding garbage', () => { + should(Base.base62p.decode('®')).be.exactly(null); + should(Base.base62p.decode('SnmsvJ1ziv')).be.exactly(null); // base62 not base62p + }); + + it('should encode number', () => { + should(Base.base62p.encode(0)).equal('48'); + should(Base.base62p.encode(1)).equal('49'); + should(Base.base62p.encode(10)).equal('4i'); + should(Base.base62p.encode(1000000)).equal('1cAFi'); + }); + + it('should decode number', () => { + Base.base62p.decodeHexToNumeric(Base.base62p.decode('48')).should.equal(0n); + Base.base62p.decodeHexToNumeric(Base.base62p.decode('49')).should.equal(1n); + Base.base62p.decodeHexToNumeric(Base.base62p.decode('57')).should.equal(61n); + Base.base62p.decodeHexToNumeric(Base.base62p.decode('4i')).should.equal(10n); + Base.base62p.decodeHexToNumeric(Base.base62p.decode('71ZRN5')).should.equal(BigInt(2147483647)); + Base.base62p.decodeHexToNumeric(Base.base62p.decode(Base.base62p.encode('73696d706c792061206c6f6e6720737472696e67'))).toString().should.equal((BigInt('0x73696d706c792061206c6f6e6720737472696e67')).toString()); + }); + + it('should encode big numbers', () => { + const x = BigInt("340282366920938463463374607431768211455"); + should(Base.base62p.encode(x)).equal('fA84qwIaXlxujAhzMevpef'); + }); + + it('should encode big numbers', () => { + const x = BigInt("0"); + should(Base.base62p.encode(x)).equal('48'); + }); + + it('should encode hex string', () => { + const x = "0a372a50deadbeef"; + should(Base.base62p.encode(x)).equal('mR3E2wPbQQL'); + }); + + it('should encode byte array', () => { + let x = [0x0A, 0x37, 0x2A, 0x50, 0xDE, 0xAD, 0xBE, 0xEF]; + should(Base.base62p.encode(x)).equal('mR3E2wPbQQL'); + + x = [0x00, 0x0A, 0x37, 0x2A, 0x50, 0xDE, 0xAD, 0xBE, 0xEF]; + should(Base.base62p.encode(x)).equal('1sLqXXb3bu2Kz'); + }); + + it('should encode/decode a uuid', async () => { + // Here is a UUID + const uuid = 'c939d4cd-3923-44ed-a2d5-450776bdfce9'; + + // Encoding the UUID should return work + should(Base.base62p.encode(uuid)).be.exactly('dULVbFAZK1ptGvuoRTAPTr'); + + // Decoding the value should return the uppercase hex value + should(Base.base62p.decode('dULVbFAZK1ptGvuoRTAPTr')).be.exactly('C939D4CD392344EDA2D5450776BDFCE9'); + + // Formatting the hex value as a UUID should match the original string + should(Base.base62p.getUUIDFromHex('C939D4CD392344EDA2D5450776BDFCE9')).be.exactly(uuid); + should(Base.base62p.getUUIDFromHex('C939D4CD392344EDA2D5450776BDFCE9', false)).be.exactly(uuid.toUpperCase()); + + // Should handle a random value + const random = Crypto.randomUUID(); + should(Base.base62p.getUUIDFromHex(Base.base62p.decode(Base.base62p.encode(random)))); + }); + + it('can encode and decode ObjectId', () => { + + // Create a mongo object id instance + const objId = new ObjectId(); + objId.should.be.instanceOf(ObjectId); + + // Encode it to base62p + const id = Base.base62p.encode(objId); + id.should.be.ok().and.be.a.String(); + + // Decode it + Base.base62p.decode(id).toLowerCase().should.equal(objId.toString()); + ObjectId.isValid(Base.base62p.decode(id)).should.be.equal(true); + + const decodeObjId = new ObjectId(Base.base62p.decode(id).toLowerCase()); + decodeObjId.toString().should.equal(objId.toString()); + decodeObjId.equals(objId).should.be.equal(true); + + }); + + it('can encode and decode ObjectId with prefix', () => { + + // Create a mongo object id instance + const objId = new ObjectId(); + objId.should.be.instanceOf(ObjectId); + + // Encode it to base62p + const id = Base.base62p.encodeWithPrefix(objId, 'ac_'); + id.should.be.ok().and.be.a.String(); + id.should.startWith('ac_'); + id.should.not.endWith('ac_'); + + // Decode it + Base.base62p.decodeWithPrefix(id, 'ac_').toLowerCase().should.equal(objId.toString()); + ObjectId.isValid(Base.base62p.decodeWithPrefix(id, 'ac_')).should.be.equal(true); + + const decodeObjId = new ObjectId(Base.base62p.decodeWithPrefix(id, 'ac_').toLowerCase()); + decodeObjId.toString().should.equal(objId.toString()); + decodeObjId.equals(objId).should.be.equal(true); + + }); + + it('should generate tokens', () => { + const res = Base.base62p.generateToken(12, 'prefix_'); + res.should.startWith('prefix_'); + res.length.should.be.greaterThan(20); + }); + + it('should generate tokens with no prefix', () => { + const res = Base.base62p.generateToken(12); + res.length.should.be.greaterThan(13); + }); + + it('should generate tokens with no prefix or specific length', () => { + const res = Base.base62p.generateToken(); + res.length.should.be.greaterThan(8); + }); + + it('should generate bytes as a typed array', () => { + const res = Base.base62p.generateBytes(10, { array: true }); + res.length.should.be.exactly(10); + res.should.be.instanceOf(Uint8Array); + }); + + it('base62p should preserve leading zeros', async () => { + should(Base.base62p.decode(Base.base62p.encode('00'), 0)).be.exactly('00'); + should(Base.base62p.decode(Base.base62p.encode('0039d4cd-3923-44ed-a2d5-450776bdfce9'))).be.exactly('0039D4CD392344EDA2D5450776BDFCE9'); + should(Base.base62p.decode(Base.base62p.encode('0039D4CD392344EDA2D5450776BDFCE9'))).be.exactly('0039D4CD392344EDA2D5450776BDFCE9'); + should(Base.base62p.decode(Base.base62p.encode('00000000000000000000000000000000'))).be.exactly('00000000000000000000000000000000'); + }); + + +}); \ No newline at end of file