From 7fe7a7ae7b7583c245792806ad5289232f084cff Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Thu, 6 Jun 2024 23:00:38 +1000 Subject: [PATCH] feat: add proquint multibase Ref: https://github.com/multiformats/multibase/blob/master/rfcs/Proquint.md --- package.json | 4 ++ src/bases/proquint.ts | 87 ++++++++++++++++++++++++++++++++ src/basics.ts | 3 +- test/test-multibase-spec.spec.ts | 53 ++++++++++++++++--- test/test-multibase.spec.ts | 7 ++- 5 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 src/bases/proquint.ts diff --git a/package.json b/package.json index c53c475..67a5112 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,10 @@ "types": "./dist/src/bases/identity.d.ts", "import": "./dist/src/bases/identity.js" }, + "./bases/proquint": { + "types": "./dist/src/bases/proquint.d.ts", + "import": "./dist/src/bases/proquint.js" + }, "./bases/interface": { "types": "./dist/src/bases/interface.d.ts", "import": "./dist/src/bases/interface.js" diff --git a/src/bases/proquint.ts b/src/bases/proquint.ts new file mode 100644 index 0000000..77b4d0c --- /dev/null +++ b/src/bases/proquint.ts @@ -0,0 +1,87 @@ +import { from } from './base.js' + +const consonants = 'bdfghjklmnprstvz' +const vowels = 'aiou' + +function consonantIndex (c: string): number { + const idx = consonants.indexOf(c) + if (idx === -1) { + throw new Error(`Non-proquint character: ${c}`) + } + return idx +} + +function vowelIndex (v: string): number { + const idx = vowels.indexOf(v) + if (idx === -1) { + throw new Error(`Non-proquint character: ${v}`) + } + return idx +} + +export const proquint = from({ + name: 'proquint', + prefix: 'p', + encode: (input: Uint8Array): string => { + // blocks of 16 bits in the pattern: + // 4 bits = consonant + // 2 bits = vowel + // 4 bits = consonant + // 2 bits = vowel + // 4 bits = consonant + // '-' + let ret = 'ro-' + for (let i = 0; i < input.length; i += 2) { + let y = input[i] << 8 + if (i + 1 !== input.length) { + y |= input[i + 1] + } + ret += consonants[y >> 12 & 0xf] + ret += vowels[(y >> 10) & 0x03] + ret += consonants[(y >> 6) & 0x0f] + if (i + 1 !== input.length) { + ret += vowels[(y >> 4) & 0x03] + ret += consonants[y & 0x0f] + } + if (i + 2 < input.length) { + ret += '-' + } + } + + return ret + }, + decode: (input: string): Uint8Array => { + if (!input.startsWith('ro-')) { + throw new Error('Invalid proquint string') + } + input = input.slice(3) + const out = [] + let i = 0 + while (i < input.length) { + const hasFive = input.length - i >= 5 + // must have at least 3 + if (!hasFive && input.length - i < 3) { + throw new Error('Unexpected end of data') + } + let y = consonantIndex(input[i++]) << 12 + y |= vowelIndex(input[i++]) << 10 + y |= consonantIndex(input[i++]) << 6 + if (hasFive) { + y |= vowelIndex(input[i++]) << 4 + y |= consonantIndex(input[i++]) + } + out.push(y >> 8) + if (hasFive) { + out.push(y & 0xff) + if (input[i] === '-') { + if (i + 1 === input.length) { + throw new Error('Unexpected end of data') + } + i++ + } + } + } + + return Uint8Array.from(out) + } +}) diff --git a/src/basics.ts b/src/basics.ts index 6051352..6c7c5d6 100644 --- a/src/basics.ts +++ b/src/basics.ts @@ -9,13 +9,14 @@ import * as base58 from './bases/base58.js' import * as base64 from './bases/base64.js' import * as base8 from './bases/base8.js' import * as identityBase from './bases/identity.js' +import * as proquint from './bases/proquint.js' import * as json from './codecs/json.js' import * as raw from './codecs/raw.js' import * as identity from './hashes/identity.js' import * as sha2 from './hashes/sha2.js' import { CID, hasher, digest, varint, bytes } from './index.js' -export const bases = { ...identityBase, ...base2, ...base8, ...base10, ...base16, ...base32, ...base36, ...base45, ...base58, ...base64, ...base256emoji } +export const bases = { ...identityBase, ...base2, ...base8, ...base10, ...base16, ...base32, ...base36, ...base45, ...base58, ...base64, ...base256emoji, ...proquint } export const hashes = { ...sha2, ...identity } export const codecs = { raw, json } diff --git a/test/test-multibase-spec.spec.ts b/test/test-multibase-spec.spec.ts index 3b00897..64878c5 100644 --- a/test/test-multibase-spec.spec.ts +++ b/test/test-multibase-spec.spec.ts @@ -32,7 +32,8 @@ const encoded = [ ['base64pad', 'MRGVjZW50cmFsaXplIGV2ZXJ5dGhpbmchIQ=='], ['base64url', 'uRGVjZW50cmFsaXplIGV2ZXJ5dGhpbmchIQ'], ['base64urlpad', 'URGVjZW50cmFsaXplIGV2ZXJ5dGhpbmchIQ=='], - ['base256emoji', 'πŸš€πŸ’›βœ‹πŸ’ƒβœ‹πŸ˜»πŸ˜ˆπŸ₯ΊπŸ€€πŸ€πŸŒŸπŸ’βœ‹πŸ˜…βœ‹πŸ’¦βœ‹πŸ₯ΊπŸƒπŸ˜ˆπŸ˜΄πŸŒŸπŸ˜»πŸ˜πŸ‘πŸ‘'] + ['base256emoji', 'πŸš€πŸ’›βœ‹πŸ’ƒβœ‹πŸ˜»πŸ˜ˆπŸ₯ΊπŸ€€πŸ€πŸŒŸπŸ’βœ‹πŸ˜…βœ‹πŸ’¦βœ‹πŸ₯ΊπŸƒπŸ˜ˆπŸ˜΄πŸŒŸπŸ˜»πŸ˜πŸ‘πŸ‘'], + ['proquint', 'pro-hidoj-katoj-kunuh-lanod-kudon-lonoj-fadoj-linoj-lanun-lidom-kojov-kisod-fah'] ] }, { @@ -62,7 +63,8 @@ const encoded = [ ['base64pad', 'MeWVzIG1hbmkgIQ=='], ['base64url', 'ueWVzIG1hbmkgIQ'], ['base64urlpad', 'UeWVzIG1hbmkgIQ=='], - ['base256emoji', 'πŸš€πŸƒβœ‹πŸŒˆπŸ˜…πŸŒ·πŸ€€πŸ˜»πŸŒŸπŸ˜…πŸ‘'] + ['base256emoji', 'πŸš€πŸƒβœ‹πŸŒˆπŸ˜…πŸŒ·πŸ€€πŸ˜»πŸŒŸπŸ˜…πŸ‘'], + ['proquint', 'pro-lojoj-lasob-kujod-kunon-fabod'] ] }, { @@ -92,7 +94,8 @@ const encoded = [ ['base64pad', 'MaGVsbG8gd29ybGQ='], ['base64url', 'uaGVsbG8gd29ybGQ'], ['base64urlpad', 'UaGVsbG8gd29ybGQ='], - ['base256emoji', 'πŸš€πŸ˜΄βœ‹πŸ€πŸ€πŸ˜“πŸ˜…βœ”πŸ˜“πŸ₯ΊπŸ€πŸ˜³'] + ['base256emoji', 'πŸš€πŸ˜΄βœ‹πŸ€πŸ€πŸ˜“πŸ˜…βœ”πŸ˜“πŸ₯ΊπŸ€πŸ˜³'], + ['proquint', 'pro-kodoj-kudos-kusob-litoz-lanos-kib'] ] }, { @@ -122,7 +125,8 @@ const encoded = [ ['base64pad', 'MAHllcyBtYW5pICE='], ['base64url', 'uAHllcyBtYW5pICE'], ['base64urlpad', 'UAHllcyBtYW5pICE='], - ['base256emoji', 'πŸš€πŸš€πŸƒβœ‹πŸŒˆπŸ˜…πŸŒ·πŸ€€πŸ˜»πŸŒŸπŸ˜…πŸ‘'] + ['base256emoji', 'πŸš€πŸš€πŸƒβœ‹πŸŒˆπŸ˜…πŸŒ·πŸ€€πŸ˜»πŸŒŸπŸ˜…πŸ‘'], + ['proquint', 'pro-badun-kijug-fadot-kajov-kohob-fah'] ] }, { @@ -152,13 +156,30 @@ const encoded = [ ['base64pad', 'MAAB5ZXMgbWFuaSAh'], ['base64url', 'uAAB5ZXMgbWFuaSAh'], ['base64urlpad', 'UAAB5ZXMgbWFuaSAh'], - ['base256emoji', 'πŸš€πŸš€πŸš€πŸƒβœ‹πŸŒˆπŸ˜…πŸŒ·πŸ€€πŸ˜»πŸŒŸπŸ˜…πŸ‘'] + ['base256emoji', 'πŸš€πŸš€πŸš€πŸƒβœ‹πŸŒˆπŸ˜…πŸŒ·πŸ€€πŸ˜»πŸŒŸπŸ˜…πŸ‘'], + ['proquint', 'pro-babab-lojoj-lasob-kujod-kunon-fabod'] ] }, + + // RFC9285 examples { input: 'AB', tests: [['base45', 'RBB8']] }, { input: 'Hello!!', tests: [['base45', 'R%69 VD92EX0']] }, { input: 'base-45', tests: [['base45', 'RUJCLQE7W581']] }, - { input: 'ietf!', tests: [['base45', 'RQED8WEX0']] } + { input: 'ietf!', tests: [['base45', 'RQED8WEX0']] }, + + // proquint spec examples, IPv4 addresses + { input: Uint8Array.from([127, 0, 0, 1]), tests: [['proquint', 'pro-lusab-babad']] }, // 127.0.0.1 + { input: Uint8Array.from([63, 84, 220, 193]), tests: [['proquint', 'pro-gutih-tugad']] }, // 63.84.220.193 + { input: Uint8Array.from([63, 118, 7, 35]), tests: [['proquint', 'pro-gutuk-bisog']] }, // 63.118.7.35 + { input: Uint8Array.from([140, 98, 193, 141]), tests: [['proquint', 'pro-mudof-sakat']] }, // 140.98.193.141 + { input: Uint8Array.from([64, 255, 6, 200]), tests: [['proquint', 'pro-haguz-biram']] }, // 64.255.6.200 + { input: Uint8Array.from([128, 30, 52, 45]), tests: [['proquint', 'pro-mabiv-gibot']] }, // 128.30.52.45 + { input: Uint8Array.from([147, 67, 119, 2]), tests: [['proquint', 'pro-natag-lisaf']] }, // 147.67.119.2 + { input: Uint8Array.from([212, 58, 253, 68]), tests: [['proquint', 'pro-tibup-zujah']] }, // 212.58.253.68 + { input: Uint8Array.from([216, 35, 68, 215]), tests: [['proquint', 'pro-tobog-higil']] }, // 216.35.68.215 + { input: Uint8Array.from([216, 68, 232, 21]), tests: [['proquint', 'pro-todah-vobij']] }, // 216.68.232.21 + { input: Uint8Array.from([198, 81, 129, 136]), tests: [['proquint', 'pro-sinid-makam']] }, // 198.81.129.136 + { input: Uint8Array.from([12, 110, 110, 204]), tests: [['proquint', 'pro-budov-kuras']] } // 12.110.110.204 ] describe('spec test', () => { @@ -169,13 +190,15 @@ describe('spec test', () => { const base = bases[name as keyof typeof bases] describe(name, () => { + const byteInput = typeof input === 'string' ? fromString(input) : input + it(`should encode from buffer [${input}]`, () => { - const out = base.encode(fromString(input)) + const out = base.encode(byteInput) assert.deepStrictEqual(out, output) }) it(`should decode from string [${input}]`, () => { - assert.deepStrictEqual(base.decode(output), fromString(input)) + assert.deepStrictEqual(base.decode(output), byteInput) }) }) } @@ -187,6 +210,10 @@ describe('spec test', () => { if (base.name === 'identity') { return this.skip() } + if (base.name === 'proquint') { + assert.throws(() => base.decode('pro-^!@$%!#$%@#y'), `Non-${base.name} character`) + return + } assert.throws(() => base.decode(base.prefix + '^!@$%!#$%@#y'), `Non-${base.name} character`) }) @@ -196,4 +223,14 @@ describe('spec test', () => { // not enough input chars, should be multiple of 3 or multiple of 3 + 2 assert.throws(() => bases.base45.decode('R%69 VD92EX'), 'Unexpected end of data') }) + + it('proquint should fail with invalid input', () => { + assert.throws(() => bases.proquint.decode('pro-lojoj-lasob-kujod-kunon-'), 'Unexpected end of data') + assert.throws(() => bases.proquint.decode('pro-lojoj-lasob-kujod-kunon-f'), 'Unexpected end of data') + assert.throws(() => bases.proquint.decode('pro-lojoj-lasob-kujod-kunon-fa'), 'Unexpected end of data') + assert.throws(() => bases.proquint.decode('pro-lojoj-lasob-kujod-kunon-fabo'), 'Unexpected end of data') + assert.throws(() => bases.proquint.decode('plojoj-lasob-kujod-kunon-fabod'), 'Invalid proquint string') + assert.throws(() => bases.proquint.decode('prlojoj-lasob-kujod-kunon-fabod'), 'Invalid proquint string') + assert.throws(() => bases.proquint.decode('prolojoj-lasob-kujod-kunon-fabod'), 'Invalid proquint string') + }) }) diff --git a/test/test-multibase.spec.ts b/test/test-multibase.spec.ts index 1750044..fe8ee4b 100644 --- a/test/test-multibase.spec.ts +++ b/test/test-multibase.spec.ts @@ -10,6 +10,7 @@ import * as b45 from '../src/bases/base45.js' import * as b58 from '../src/bases/base58.js' import * as b64 from '../src/bases/base64.js' import * as b8 from '../src/bases/base8.js' +import * as proquint from '../src/bases/proquint.js' import * as bytes from '../src/bytes.js' const { base16, base32, base58btc, base64 } = { ...b16, ...b32, ...b58, ...b64 } @@ -65,7 +66,7 @@ describe('multibase', () => { const buff = bytes.fromString('test') const nonPrintableBuff = Uint8Array.from([239, 250, 254]) - const baseTest = (bases: typeof b2 | typeof b8 | typeof b10 | typeof b16 | typeof b32 | typeof b36 | typeof b45 | typeof b58 | typeof b64): void => { + const baseTest = (bases: typeof b2 | typeof b8 | typeof b10 | typeof b16 | typeof b32 | typeof b36 | typeof b45 | typeof b58 | typeof b64 | typeof proquint): void => { for (const base of Object.values(bases)) { if (((base as { name: string })?.name) !== '') { it(`encode/decode ${base.name}`, () => { @@ -123,6 +124,10 @@ describe('multibase', () => { baseTest(b64) }) + describe('proquint', () => { + baseTest(proquint) + }) + it('multibase mismatch', () => { const b64 = base64.encode(bytes.fromString('test')) const msg = `Unable to decode multibase string "${b64}", base32 decoder only supports inputs prefixed with ${base32.prefix}`