Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for Multibase, Multihash and Hashlinks #263

Merged
Merged
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@
},
"dependencies": {
"bn.js": "^5.2.0",
"borc": "^3.0.0",
"buffer": "^6.0.3",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"events": "^3.3.0",
"js-sha256": "^0.9.0",
"reflect-metadata": "^0.1.13",
"multibase": "4.0.2",
"multihashes": "^4.0.2",
berendsliedrecht marked this conversation as resolved.
Show resolved Hide resolved
"node-fetch": "^2.6.1",
"reflect-metadata": "^0.1.13",
"tsyringe": "^4.5.0",
"uuid": "^8.3.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()
}
}
138 changes: 138 additions & 0 deletions src/utils/HashlinkEncoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import cbor from 'borc'
import { sha256 } from 'js-sha256'
import { BufferEncoder } from './BufferEncoder'
import { Buffer } from './buffer'
import { MultibaseEncoder, BaseName } from './MultibaseEncoder'
import { MultihashEncoder } from './MultihashEncoder'

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

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

const hexTable = {
urls: 0x0f,
contentType: 0x0e,
}

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

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

const [, checksum, encodedMetadata] = hashlinkList
return encodedMetadata ? { checksum, metadata: this.decodeMetadata(encodedMetadata) } : { checksum }
} else {
throw new Error(`Invalid hashlink: ${hashlink}`)
}
}

/**
*
* Validates a hashlink
*
* @param hashlink the hashlink that needs validating
*
* @returns a boolean whether the hashlink is valid
*
* */

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

private static encodeMultihashEncoder(
buffer: Buffer | Uint8Array,
hashName: 'sha2-256',
baseEncoding: 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, baseEncoding)
return BufferEncoder.toUtf8String(mb)
}

private static encodeMetadata(metadata: Metadata, baseEncoding: BaseName): string {
const metadataMap = new Map<number, unknown>()

for (const key of Object.keys(metadata)) {
if (key === 'urls' || key === 'contentType') {
metadataMap.set(hexTable[key], metadata[key])
} else {
throw new Error(`Invalid metadata: ${metadata}`)
}
}

const cborData = cbor.encode(metadataMap)

const multibaseMetadata = MultibaseEncoder.encode(cborData, baseEncoding)

return BufferEncoder.toUtf8String(multibaseMetadata)
}

private static decodeMetadata(mb: string): Metadata {
const obj = { urls: [] as string[], 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) => {
if (key === hexTable.urls) {
obj.urls = value
} else if (key === hexTable.contentType) {
obj.contentType = value
} else {
throw new Error(`Invalid metadata property: ${key}:${value}`)
}
})
return obj
} catch (error) {
throw new Error(`Invalid metadata: ${mb}, ${error}`)
}
}
}
47 changes: 47 additions & 0 deletions src/utils/MultibaseEncoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import multibase from 'multibase'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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 } {
if (this.isValid(data)) {
const baseName = multibase.encodingFromData(data).name
return { data: multibase.decode(data), baseName }
}
throw new Error(`Invalid multibase: ${data}`)
}

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

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

/**
*
* Decodes the multihash
*
* @param data the multihash that has to be decoded
*
* @returns object with the data and the hashing algorithm
*/
public static decode(data: Uint8Array): { data: Uint8Array; hashName: string } {
if (this.isValid(data)) {
const decodedHash = multihash.decode(data)
return { data: decodedHash.digest, hashName: decodedHash.name }
}
throw new Error(`Invalid multihash: ${data}`)
}

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

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:zCwPSdabLuj3jue1qYujzunnKwpL4myKdyeqySyFhnzZ8qeLeC1U6N5eycFD'

const invalidHashlink =
'hl:gQmWvQxTqbqwlkhhhhh9w8e7rJenbyYTWkjgF3e:z51a94WAQfNv1KEcPeoV3V2isZFPFqSzE9ghNFQ8DuQu4hTHtFRug8SDgug14Ff'

const invalidMetadata =
'hl:zQmWvQxTqbG2Z9HPJgG57jjwR154cKhbtJenbyYTWkjgF3e:zHCwSqQisPgCc2sMSNmHWyQtCKu4kgQVD6Q1Nhxff7uNRqN6r'

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

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

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

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

it('Decodes invalid hashlink', () => {
expect(() => {
HashlinkEncoder.decode(invalidHashlink)
}).toThrow(/^Invalid hashlink: /)
})

it('Decodes invalid metadata in hashlink', () => {
expect(() => {
HashlinkEncoder.decode(invalidMetadata)
}).toThrow(/^Invalid metadata: /)
})
})

describe('isValid()', () => {
it('Validate hashlink', () => {
expect(HashlinkEncoder.isValid(validHashlink)).toEqual(true)
})
it('Validate invalid hashlink', () => {
expect(HashlinkEncoder.isValid(invalidHashlink)).toEqual(false)
})
})
})
Loading