import { Singleton, Inject } from "@extollo/di" import { Unit, Logging, Config, Application } from "@extollo/lib" import { FirebaseUnit } from "./FirebaseUnit" import { BlockEncounterTransaction, BlockResource, BlockResourceItem, BlockTransaction } from "../rtdb/BlockResource" import { TransactionResourceItem } from "../rtdb/TransactionResource" import * as openpgp from "openpgp" import * as crypto from "crypto" import { collect, uuid_v4 } from "@extollo/util" import {ExposureResourceItem} from "../rtdb/ExposureResource"; /** * Utility wrapper class for a block in the chain. */ export class Block implements BlockResourceItem { firebaseID: string seqID: number uuid: string transactions: BlockTransaction[] timestamp: number lastBlockHash: string lastBlockUUID: string proof: string get config(): Config { return Application.getApplication().make(Config) } constructor(rec: BlockResourceItem) { this.firebaseID = rec.firebaseID this.seqID = rec.seqID this.uuid = rec.uuid this.transactions = rec.transactions this.lastBlockHash = rec.lastBlockHash this.lastBlockUUID = rec.lastBlockUUID this.proof = rec.proof this.timestamp = rec.timestamp } /** Returns true if this is the genesis block. */ async isGenesis() { // first block will be guaranteed uuid 0000 if (this.uuid !== '0000') { return false } const proof = this.proof const publicKey = this.config.get("app.gpg.key.public") const result = await openpgp.verify({ publicKeys: await openpgp.readKey({ armoredKey: publicKey, }), message: await openpgp.readMessage({ armoredMessage: proof, }), }) return !!(await result.signatures?.[0]?.verified) } /** Generate the hash for this block. */ hash() { return crypto.createHash('sha256') .update(this.toString(), 'utf-8') .digest('hex') } /** Cast the Block's data to a plain object. */ toItem(): BlockResourceItem { return { seqID: this.seqID, firebaseID: this.firebaseID, uuid: this.uuid, transactions: this.transactions, lastBlockHash: this.lastBlockHash, lastBlockUUID: this.lastBlockUUID, proof: this.proof, timestamp: this.timestamp, } } /** Generate the deterministic hash-able string. */ toString() { return [ this.uuid, JSON.stringify(this.transactions || [], undefined, 0), this.lastBlockHash, this.lastBlockUUID, ].join('%') } } /** * Interface representing a federated peer. */ export interface Peer { host: string, name?: string, } /** * Blockchain Unit * --------------------------------------- * Main service for interacting with the contact blockchain. */ @Singleton() export class Blockchain extends Unit { @Inject() protected readonly logging!: Logging @Inject() protected readonly firebase!: FirebaseUnit @Inject() protected readonly config!: Config /** * Returns true if the given host is registered as a peer. * @param host */ public async hasPeer(host: string): Promise { const peers = await this.getPeers() return peers.some(peer => peer.host.toLowerCase() === host.toLowerCase()) } /** * Get a list of all registered peers. */ public async getPeers(): Promise { const data = await this.firebase.ref('peers').once('value') return (data.val() as Peer[]) || [] } /** * Register a new host as a peer of this instance. * @param peer */ public async registerPeer(peer: Peer) { if (!(await this.hasPeer(peer.host))) { await this.firebase.ref('peers').push().set(peer) } } /** * Given an array of blocks in chain-order, validate the chain. * @param chain * @return boolean - true if the chain is valid */ public async validate(chain: Block[]) { const blocks = collect(chain) return ( await blocks.promiseMap(async (block, idx) => { if ( await block.isGenesis() ) { return true } const previous: Block | undefined = blocks.at(idx - 1) if ( !previous ) { this.logging.debug(`Chain is invalid: block ${idx} is missing previous ${idx - 1}.`) return false; } if ( !(await this.validateProofOfWork(block, previous)) ) { this.logging.debug(`Chain is invalid: block ${idx} failed proof of work validation`) return false; } const pass = ( block.lastBlockUUID === previous.uuid && block.lastBlockHash === previous.hash() ) if ( !pass ) { this.logging.debug(`Chain is invalid: block ${idx} does not match previous.`) this.logging.debug({ lastBlockUUID: block.lastBlockUUID, computedLastUUID: previous.uuid, lastBlockHash: block.lastBlockHash, computedLastHash: previous.hash(), }) } return pass }) ).every(Boolean) } public async refresh() { } /** * Submit a group of encounter transactions to be added to the chain. * @param group */ public async submitTransactions(group: [TransactionResourceItem, TransactionResourceItem]) { const lastBlock = await this.getLastBlock() this.logging.verbose('Last block:') this.logging.verbose(lastBlock) const block: BlockResourceItem = { timestamp: (new Date).getTime(), uuid: uuid_v4(), transactions: group.map(item => this.getEncounterTransaction(item)), lastBlockHash: lastBlock!.hash(), lastBlockUUID: lastBlock!.uuid, proof: await this.generateProofOfWork(lastBlock!), firebaseID: '', seqID: -1, } await (this.app().make(BlockResource)).push(block) return new Block(block) } /** * Submit the given exposure notifications onto the blockchain. * @param exposures */ public async submitExposures(...exposures: ExposureResourceItem[]) { const lastBlock = await this.getLastBlock() this.logging.verbose('Last block:') this.logging.verbose(lastBlock) const block: BlockResourceItem = { timestamp: (new Date).getTime(), uuid: uuid_v4(), transactions: exposures, lastBlockHash: lastBlock!.hash(), lastBlockUUID: lastBlock!.uuid, proof: await this.generateProofOfWork(lastBlock), firebaseID: '', seqID: -1, } await (this.app().make(BlockResource)).push(block) return new Block(block) } /** * Instantiate the genesis block of the entire chain. */ public async getGenesisBlock(): Promise { const message = openpgp.Message.fromText("0000") const privateKey = this.config.get("app.gpg.key.private") return new Block({ timestamp: (new Date).getTime(), uuid: '0000', transactions: [], lastBlockHash: '', lastBlockUUID: '', proof: (await openpgp.sign({ message, privateKeys: await openpgp.readKey({ armoredKey: privateKey }), })), firebaseID: '', seqID: -1, }) } /** * Get the last block in the blockchain, or push the genesis if one doesn't already exist. */ public async getLastBlock(): Promise { const rec: BlockResourceItem | undefined = await BlockResource.collect().last() if (rec) return new Block(rec) const genesis = (await this.getGenesisBlock()).toItem() await (this.app().make(BlockResource)).push(genesis) return new Block(genesis) } /** * Get a list of all blocks in the chain, in order. */ public async read(): Promise { return BlockResource.collect().all() } /** * Given a client-submitted transaction, generate a block encounter transaction record. * @param item * @protected */ protected getEncounterTransaction(item: TransactionResourceItem): BlockEncounterTransaction { return { combinedHash: item.combinedHash, timestamp: item.timestamp, encodedGPSLocation: item.encodedGPSLocation, } } /** * Generate a proof of work string for the block that follows lastBlock. * @param lastBlock * @protected */ protected async generateProofOfWork(lastBlock: Block): Promise { const hashString = lastBlock.hash() const privateKey = this.config.get("app.gpg.key.private") const message = openpgp.Message.fromText(hashString) // Sign the hash using the server's private key return (await openpgp.sign({ message, privateKeys: await openpgp.readKey({ armoredKey: privateKey, }) })) } /** * Validate that the proof of work of currentBlock is accurate relative to lastBlock. * @param currentBlock * @param lastBlock * @protected */ protected async validateProofOfWork(currentBlock: Block, lastBlock: Block): Promise { const proof = lastBlock.proof const publicKey = this.config.get("app.gpg.key.public") const result = await openpgp.verify({ publicKeys: await openpgp.readKey({ armoredKey: publicKey, }), message: await openpgp.readMessage({ armoredMessage: proof, }), }) return !!(await result.signatures?.[0]?.verified) } }