From 8b25ba39b8b672ba43e0b5f09000cad2332ec74e Mon Sep 17 00:00:00 2001 From: Chip Wasson Date: Tue, 2 Apr 2024 16:45:09 -0600 Subject: [PATCH] Add PoW module --- src/app.controller.spec.ts | 2 +- src/app.module.ts | 2 + src/pow/pow.controller.ts | 24 +++++++++++ src/pow/pow.module.ts | 16 +++++++ src/pow/pow.service.ts | 88 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/pow/pow.controller.ts create mode 100644 src/pow/pow.module.ts create mode 100644 src/pow/pow.service.ts diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index d22f389..4110be6 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -16,7 +16,7 @@ describe('AppController', () => { describe('root', () => { it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); + expect(appController.getHello()).toBe("Hello World! This is Chip's generalized API for fetching information and things. You can contact me on mastodon @chip@talking.dev."); }); }); }); diff --git a/src/app.module.ts b/src/app.module.ts index 91f6de1..7703e1c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { redisStore } from 'cache-manager-redis-yet'; import { RedirectsModule } from './redirects/redirects.module'; import { FileModule } from './file/file.module'; import { FocoLiveModule } from './foco-live/foco-live.module'; +import { PowModule } from './pow/pow.module'; @Module({ imports: [ @@ -78,6 +79,7 @@ import { FocoLiveModule } from './foco-live/foco-live.module'; RedirectsModule, FileModule, FocoLiveModule, + PowModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/pow/pow.controller.ts b/src/pow/pow.controller.ts new file mode 100644 index 0000000..da27fbf --- /dev/null +++ b/src/pow/pow.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { PowService } from './pow.service'; +import { ApiBody, ApiQuery, ApiTags } from '@nestjs/swagger'; + +@ApiTags('pow') +@Controller('pow') +export class PowController { + constructor( + private readonly powService: PowService, + ) { } + + + @Get('challenge') + async generateChallenge() { + return this.powService.generateChallenge(); + } + + @Post('challenge') + @ApiBody({ schema: { properties: { challenge: { type: 'string' }, proof: { type: 'string' } } } }) + async verifyChallenge(@Body() body: { challenge: string, proof: string }) { + return this.powService.verifyChallenge(body.challenge, body.proof); + } + +} diff --git a/src/pow/pow.module.ts b/src/pow/pow.module.ts new file mode 100644 index 0000000..6a81dbd --- /dev/null +++ b/src/pow/pow.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { PowService } from './pow.service'; +import { CacheModule } from '@nestjs/cache-manager'; +import { PowController } from './pow.controller'; + +@Module({ + imports: [ + CacheModule.register({ + ttl: 5, + max: 10, + }), + ], + providers: [PowService], + controllers: [PowController] +}) +export class PowModule { } diff --git a/src/pow/pow.service.ts b/src/pow/pow.service.ts new file mode 100644 index 0000000..2722930 --- /dev/null +++ b/src/pow/pow.service.ts @@ -0,0 +1,88 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Cache } from 'cache-manager'; +import { randomBytes, createHash } from 'crypto'; + + +@Injectable() +export class PowService { + private readonly logger = new Logger(PowService.name); + private difficulty = 5; + + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + ) { } + + /** + * Generate a proof of work challenge, stored to redis for verification within + * the next 10 seconds. + */ + async generateChallenge() { + const challenge = this.generateRandom256BitString(); + console.log(challenge) + await this.cacheManager.set(challenge, true, 60 * 1000); + return challenge; + } + + generateRandom256BitString() { + const randomString = randomBytes(32).toString('hex'); + return randomString; + } + + + hashAndCheck(string: string) { + return this.hashPassesDifficulty(this.hashString(string), this.difficulty); + } + + hashPassesDifficulty(hash: string, difficulty: number) { + return hash.startsWith('0'.repeat(difficulty)); + } + + /** + * Verify that the proof of work submitted has a leading number of + * zeroes equal to the challenge length and the challenge exists. + */ + async verifyChallenge(challenge: string, proof: string): Promise { + const expected = await this.cacheManager.get(challenge); + return expected ? this.hashAndCheck(proof + challenge) : false; + } + + async markChallengeAsComplete(challenge: string) { + await this.cacheManager.del(challenge); + } + + /** + * Perform a proof of work challenge to find a proof that hashes to a value + */ + async performChallenge(challenge: string) { + let proof = this.generateRandom256BitString(); + let hash = this.hashString(proof + challenge); + while (!this.hashPassesDifficulty(hash, this.difficulty)) { + proof = this.generateRandom256BitString(); + hash = this.hashString(proof + challenge); + } + return { proof, hash }; + } + + /** + * sha512 the provided string and return the result. + */ + hashString(input: string) { + return createHash('sha512').update(input).digest('hex'); + } + + /** + * Set the difficulty of the proof of work challenge. + */ + setDifficulty(difficulty: number) { + this.difficulty = difficulty; + } + + /** + * Get the current difficulty of the proof of work challenge. + */ + getDifficulty() { + return this.difficulty; + } + +}