Skip to content

Commit

Permalink
Feat: Support for Multibase, Multihash and Hashlinks
Browse files Browse the repository at this point in the history
Signed-off-by: Berend Sliedrecht <berend@animo.id>
  • Loading branch information
berendsliedrecht committed May 11, 2021
1 parent 33c85ba commit 71863c8
Show file tree
Hide file tree
Showing 10 changed files with 453 additions and 39 deletions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,16 @@
"prepare": "husky install"
},
"dependencies": {
"bignumber.js": "^9.0.1",
"bn.js": "^5.2.0",
"buffer": "^6.0.3",
"cbor": "^7.0.5",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"events": "^3.3.0",
"js-sha256": "^0.9.0",
"multibase": "^4.0.4",
"multihashes": "^4.0.2",
"reflect-metadata": "^0.1.13",
"node-fetch": "^2.6.1",
"tsyringe": "^4.5.0",
Expand Down
2 changes: 1 addition & 1 deletion src/decorators/attachment/Attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface AttachmentOptions {
export interface AttachmentDataOptions {
base64?: string
json?: Record<string, unknown>
links?: []
links?: string[]
jws?: Record<string, unknown>
sha256?: string
}
Expand Down
13 changes: 13 additions & 0 deletions src/utils/BufferEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,17 @@ export class BufferEncoder {
public static fromBase64(base64: string) {
return Buffer.from(base64, 'base64')
}

/**
* Decode string into buffer.
*
* @param str the string to decode into buffer format
*/
public static fromString(str: string): Uint8Array {
return Buffer.from(str)
}

public static toUtf8String(buffer: Buffer | Uint8Array) {
return Buffer.from(buffer).toString()
}
}
43 changes: 43 additions & 0 deletions src/utils/MultibaseEncoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import multibase from 'multibase'
import { Buffer } from './buffer'

export type BaseName = multibase.BaseName

export class MultibaseEncoder {
/**
*
* Encodes a buffer into a multibase
*
* @param {Uint8Array} buffer the buffer that has to be encoded
* @param {multibase.BaseName} baseName the encoding algorithm
*/
public static encode(buffer: Uint8Array, baseName: multibase.BaseName = 'base58btc') {
return multibase.encode(baseName, buffer)
}

/**
*
* Decodes a multibase into a Uint8Array
*
* @param {string} data the multibase that has to be decoded
*
* @returns {Uint8array} data the decoded multibase
* @returns {string} encodingAlgorithm name of the encoding algorithm
*/
public static decode(data: string | Uint8Array): { data: Uint8Array; baseName: string } {
const baseName = multibase.encodingFromData(data).name
return { data: multibase.decode(data), baseName }
}

/**
*
* Validates if it is a valid multibase encoded value
*
* @param {Uint8Array} data the multibase that needs to be validated
*
* @returns {boolean} bool wether the multibase value is encoded
*/
public static validate(data: string | Uint8Array): boolean {
return multibase.isEncoded(data) ? true : false
}
}
41 changes: 41 additions & 0 deletions src/utils/MultihashEncoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import multihash from 'multihashes'
import { Buffer } from './buffer'

export class MultihashEncoder {
/**
*
* Encodes a buffer into a hash
*
* @param {Uint8Array} buffer the buffer that has to be encoded
* @param {string} hashName the hashing algorithm, 'sha2-256'
*/
public static encode(buffer: Uint8Array, hashName: 'sha2-256') {
return multihash.encode(buffer, hashName)
}

/**
*
* Decodes the multihash
*
* @param {Uint8Array} data the multihash that has to be decoded
*/
public static decode(data: Uint8Array): { data: Uint8Array; hashName: string } {
const decodedHash = multihash.decode(data)
return { data: decodedHash.digest, hashName: decodedHash.name }
}

/**
*
* Validates if it is a valid mulithash
*
* @param {Uint8Array} data the multihash that needs to be validated
*/
public static validate(data: Uint8Array) {
try {
multihash.validate(data)
return true
} catch (e) {
return false
}
}
}
36 changes: 36 additions & 0 deletions src/utils/__tests__/MultibaseEncoder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { MultibaseEncoder } from '../MultibaseEncoder'
import { Buffer } from 'buffer'
import { BufferEncoder } from '../BufferEncoder'

const validData = Buffer.from('Hello World!')
const validMultibase = 'zKWfinQuRQ3ekD1danFHqvKRg9koFp8vpokUeREEgjSyHwweeKDFaxVHi'
const invalidMultibase = 'gKWfinQuRQ3ekD1danFHqvKRg9koFp8vpokUeREEgjSyHwweeKDFaxVHi'

describe('multibase', () => {
it('Encodes multibase', () => {
const multibase = BufferEncoder.toUtf8String(MultibaseEncoder.encode(validData, 'base58btc'))
expect(multibase).toEqual('z2NEpo7TZRRrLZSi2U')
})

it('Decodes multibase', () => {
const { data, baseName } = MultibaseEncoder.decode(validMultibase)
expect(BufferEncoder.toUtf8String(data)).toEqual('This is a valid base58btc encoded string!')
expect(baseName).toEqual('base58btc')
})

it('Validates valid multibase', () => {
const bool = MultibaseEncoder.validate(validMultibase)
expect(bool).toEqual(true)
})

it('Validates invalid multibase', () => {
const bool = MultibaseEncoder.validate(invalidMultibase)
expect(bool).toEqual(false)
})

it('Decodes invalid multibase', () => {
expect(() => {
MultibaseEncoder.decode(invalidMultibase)
}).toThrow(/^Unsupported encoding: g/)
})
})
36 changes: 36 additions & 0 deletions src/utils/__tests__/MultihashEncoder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { MultihashEncoder } from '../MultihashEncoder'
import { Buffer } from 'buffer'
import { BufferEncoder } from '../BufferEncoder'

const validData = Buffer.from('Hello World!')
const validMultihash = new Uint8Array([18, 12, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33])
const invalidMultihash = new Uint8Array([99, 12, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33])

describe('multihash', () => {
it('encodes multihash', () => {
const multihash = MultihashEncoder.encode(validData, 'sha2-256')
expect(multihash).toEqual(new Uint8Array([18, 12, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33]))
})

it('Decodes multihash', () => {
const { data, hashName } = MultihashEncoder.decode(validMultihash)
expect(hashName).toEqual('sha2-256')
expect(BufferEncoder.toUtf8String(data)).toEqual('Hello World!')
})

it('Validates valid multihash', () => {
const bool = MultihashEncoder.validate(validMultihash)
expect(bool).toEqual(true)
})

it('Validates invalid multihash', () => {
const bool = MultihashEncoder.validate(invalidMultihash)
expect(bool).toEqual(false)
})

it('Decodes invalid multihash', () => {
expect(() => {
MultihashEncoder.decode(invalidMultihash)
}).toThrow()
})
})
65 changes: 65 additions & 0 deletions src/utils/__tests__/hashlink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Buffer } from 'buffer'
import { Hashlink } from '../hashlink'

const validData = {
data: Buffer.from('Hello World!'),
metaData: {
urls: ['https://example.org/hw.txt'],
contentType: 'text/plain',
},
}

const invalidData = {
data: Buffer.from('Hello World!'),
metaData: {
unknownKey: 'unkownValue',
contentType: 'image/png',
},
}

const validHashlink =
'hl:zQmWvQxTqbG2Z9HPJgG57jjwR154cKhbtJenbyYTWkjgF3e:zCwQVpeF6FPqFyc4pvisK5cpW4kc358NTX6ZbhqfawZTBmXm372zoAj5oLSh'

const invalidHashlink =
'hl:zQmWvQxTqbqwlkhhhhh9w8e7rJenbyYTWkjgF3e:z51a94WAQfNv1KEcPeoV3V2isZFPFqSzE9ghNFQ8DuQu4hTHtFRug8SDgug14Ff'

const invalidMetadata =
'hl:zQmWvQxTqbG2Z9HPJgG57jjwR154cKhbtJenbyYTWkjgF3e:zHCwSqQisPgCc2sMSNmHWyQtCKu4kgQVD6Q1Nhxff7uNRqN6r'

describe('Hashlink', () => {
it('Encodes string to hashlink', () => {
const hashlink = Hashlink.encode(validData.data, 'sha2-256')
expect(hashlink).toEqual('hl:zQmWvQxTqbG2Z9HPJgG57jjwR154cKhbtJenbyYTWkjgF3e')
})

it('Encodes string and metadata to hashlink', () => {
const hashlink = Hashlink.encode(validData.data, 'sha2-256', 'base58btc', validData.metaData)
expect(hashlink).toEqual(validHashlink)
})

it('Decodes hashlink', () => {
const decodedHashlink = Hashlink.decode(validHashlink)
expect(decodedHashlink).toEqual({
checksum: 'zQmWvQxTqbG2Z9HPJgG57jjwR154cKhbtJenbyYTWkjgF3e',
metadata: { contentType: 'text/plain', urls: ['https://example.org/hw.txt'] },
})
})

it('Decodes invalid hashlink', () => {
expect(() => {
Hashlink.decode(invalidHashlink)
}).toThrow(/^invalid character 'l' in /)
})

it('Encodes invalid metadata in hashlink', () => {
expect(() => {
Hashlink.encode(validData.data, 'sha2-256', 'base58btc', invalidData.metaData)
}).toThrow(/^Metadata, /)
})

it('Decodes invalid metadata in hashlink', () => {
expect(() => {
Hashlink.decode(invalidMetadata)
}).toThrow(/^Metadata, /)
})
})
118 changes: 118 additions & 0 deletions src/utils/hashlink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import cbor from 'cbor'
import { sha256 } from 'js-sha256'
import { BufferEncoder } from './BufferEncoder'
import { Buffer } from './buffer'
import { MultibaseEncoder, BaseName } from './MultibaseEncoder'
import { MultihashEncoder } from './MultihashEncoder'
import multibase from 'multibase'

type Metadata = {
urls?: string[]
contentType?: string
}

export type HashlinkData = {
checksum: string
metadata?: Metadata
}

const URLS = 0x0f
const CONTENTTYPE = 0x0e

export class Hashlink {
/**
*
* Encodes a buffer, with optional metadata, into a hashlink
*
* @param {Buffer} buffer the buffer to encode into a hashlink
* @param {string} hashName the name of the hashing algorithm 'sha2-256'
* @param {BaseName} baseName the name of the base encoding algorithm 'base58btc'
* @param {Metadata} metadata the optional metadata in the hashlink
*/
public static encode(buffer: Buffer, hashName: 'sha2-256', baseName: BaseName = 'base58btc', metadata?: Metadata) {
const checksum = this.encodeMultihashEncoder(buffer, hashName, baseName)
const mbMetadata = metadata ? this.encodeMetadata(metadata, baseName) : null
return mbMetadata ? `hl:${checksum}:${mbMetadata}` : `hl:${checksum}`
}

/**
*
* Decodes a hashlink into HashlinkData object
*
* @param {string} hashlink the hashlink that needs decoding
*/
public static decode(hashlink: string): HashlinkData {
const hashlinkList = hashlink.split(':')
if (this.validate(hashlink)) {
const checksum = hashlinkList[1]
const metadata = hashlinkList[2] ? this.decodeMetadata(hashlinkList[2]) : null

return metadata ? { checksum, metadata } : { checksum }
} else {
throw new Error(`Hashlink, ${hashlink}, is invalid`)
}
}

public static validate(hashlink: string): boolean {
const hashlinkList = hashlink.split(':')
const validMultibase = MultibaseEncoder.validate(hashlinkList[1])
const { data } = MultibaseEncoder.decode(hashlinkList[1])
const validMultihash = MultihashEncoder.validate(data)
return validMultibase && validMultihash ? true : false
}

private static encodeMultihashEncoder(buffer: Buffer, hashName: 'sha2-256', baseName: BaseName): string {
// TODO: Support more hashing algorithms
const hash = new Uint8Array(sha256.array(buffer))
const mh = MultihashEncoder.encode(hash, hashName)
const mb = MultibaseEncoder.encode(mh, baseName)
return BufferEncoder.toUtf8String(mb)
}

private static encodeMetadata(metadata: Metadata, baseName: BaseName): string {
const metadataMap = new Map()

for (const key of Object.keys(metadata)) {
switch (key) {
case 'urls':
metadataMap.set(URLS, metadata.urls)
break
case 'contentType':
metadataMap.set(CONTENTTYPE, metadata.contentType)
break
default:
throw new Error(`Metadata, ${metadata}, is invalid`)
}
}

const cborData = cbor.encode(metadataMap)

const multibaseMetadata = MultibaseEncoder.encode(cborData, baseName)

return BufferEncoder.toUtf8String(multibaseMetadata)
}

private static decodeMetadata(mb: string): Metadata {
const obj = { urls: [], contentType: '' }
const { data } = MultibaseEncoder.decode(mb)
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cborData: Map<number, any> = cbor.decode(data)
cborData.forEach((value, key) => {
switch (key) {
case URLS:
obj.urls = value
break
case CONTENTTYPE:
obj.contentType = value
break
default:
throw new Error(`Metadata, ${key}:${value}, is invalid`)
}
})
return obj
} catch (error) {
throw new Error(`Metadata, ${mb}, is invalid: ${error}`)
}
}
}
Loading

0 comments on commit 71863c8

Please sign in to comment.