-
Notifications
You must be signed in to change notification settings - Fork 200
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feat: Support for Multibase, Multihash and Hashlinks
Signed-off-by: Berend Sliedrecht <berend@animo.id>
- Loading branch information
1 parent
33c85ba
commit 71863c8
Showing
10 changed files
with
453 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, /) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`) | ||
} | ||
} | ||
} |
Oops, something went wrong.