diff --git a/README.md b/README.md index 66072e1..b5b50e1 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,7 @@ Configuration can be set using environment variables | `DS_MAX_RECORD_DATA_SIZE` | Maximum size for `RecordsWrite` data. use `b`, `kb`, `mb`, `gb` for value | `1gb` | | `DS_WEBSOCKET_SERVER` | Whether to enable listening over `ws:`. values: `on`,`off` | `on` | | `DWN_REGISTRATION_STORE_URL` | URL to use for storage of registered DIDs. Leave unset to if DWN does not require registration (ie. open for all) | unset | +| `DWN_REGISTRATION_PROOF_OF_WORK_SEED` | Seed to generate the challenge nonce from, this allows all DWN instances in a cluster to generate the same challenge. | unset | | `DWN_REGISTRATION_PROOF_OF_WORK_ENABLED` | Require new users to complete a proof-of-work challenge | `false` | | `DWN_REGISTRATION_PROOF_OF_WORK_INITIAL_MAX_HASH` | Initial maximum allowed hash in 64 char HEX string. The more leading zeros (smaller number) the higher the difficulty. | `false` | | `DWN_TERMS_OF_SERVICE_FILE_PATH` | Required terms of service agreement if set. Value is path to the terms of service file. | unset | diff --git a/src/config.ts b/src/config.ts index 4f835bb..adc5de3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import bytes from 'bytes'; -export type Config = typeof config; +export type DwnServerConfig = typeof config; export const config = { // max size of data that can be provided with a RecordsWrite @@ -16,6 +16,7 @@ export const config = { // tenant registration feature configuration registrationStoreUrl: process.env.DWN_REGISTRATION_STORE_URL || process.env.DWN_STORAGE, + registrationProofOfWorkSeed: process.env.DWN_REGISTRATION_PROOF_OF_WORK_SEED, registrationProofOfWorkEnabled: process.env.DWN_REGISTRATION_PROOF_OF_WORK_ENABLED === 'true', registrationProofOfWorkInitialMaxHash: process.env.DWN_REGISTRATION_PROOF_OF_WORK_INITIAL_MAX_HASH, termsOfServiceFilePath: process.env.DWN_TERMS_OF_SERVICE_FILE_PATH, diff --git a/src/dwn-server.ts b/src/dwn-server.ts index c19a030..15878ef 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -7,7 +7,7 @@ import { type WebSocketServer } from 'ws'; import { HttpServerShutdownHandler } from './lib/http-server-shutdown-handler.js'; -import { type Config, config as defaultConfig } from './config.js'; +import { type DwnServerConfig, config as defaultConfig } from './config.js'; import { HttpApi } from './http-api.js'; import { setProcessHandlers } from './process-handlers.js'; import { getDWNConfig } from './storage.js'; @@ -16,12 +16,12 @@ import { RegistrationManager } from './registration/registration-manager.js'; export type DwnServerOptions = { dwn?: Dwn; - config?: Config; + config?: DwnServerConfig; }; export class DwnServer { dwn?: Dwn; - config: Config; + config: DwnServerConfig; #httpServerShutdownHandler: HttpServerShutdownHandler; #httpApi: HttpApi; #wsApi: WsApi; @@ -57,7 +57,8 @@ export class DwnServer { registrationManager = await RegistrationManager.create({ registrationStoreUrl: this.config.registrationStoreUrl, termsOfServiceFilePath: this.config.termsOfServiceFilePath, - initialMaximumAllowedHashValue: this.config.registrationProofOfWorkInitialMaxHash, + proofOfWorkChallengeNonceSeed: this.config.registrationProofOfWorkSeed, + proofOfWorkInitialMaximumAllowedHash: this.config.registrationProofOfWorkInitialMaxHash, }); this.dwn = await Dwn.create(getDWNConfig(this.config, registrationManager)); diff --git a/src/http-api.ts b/src/http-api.ts index 1a096b0..30972ae 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -14,7 +14,7 @@ import type { RequestContext } from './lib/json-rpc-router.js'; import type { JsonRpcRequest } from './lib/json-rpc.js'; import { createJsonRpcErrorResponse, JsonRpcErrorCodes } from './lib/json-rpc.js'; -import type { Config } from './config.js'; +import type { DwnServerConfig } from './config.js'; import { config } from './config.js'; import { type DwnServerError } from './dwn-error.js'; import { jsonRpcApi } from './json-rpc-api.js'; @@ -24,13 +24,13 @@ import type { RegistrationManager } from './registration/registration-manager.js const packageJson = process.env.npm_package_json ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) : {}; export class HttpApi { - #config: Config; + #config: DwnServerConfig; #api: Express; #server: http.Server; registrationManager: RegistrationManager; dwn: Dwn; - constructor(config: Config, dwn: Dwn, registrationManager: RegistrationManager) { + constructor(config: DwnServerConfig, dwn: Dwn, registrationManager: RegistrationManager) { console.log(config); this.#config = config; diff --git a/src/index.ts b/src/index.ts index e7f3d09..e77275c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { Config } from './config.js'; +export { DwnServerConfig } from './config.js'; export { DwnServer, DwnServerOptions } from './dwn-server.js'; export { HttpApi } from './http-api.js'; export { jsonRpcApi } from './json-rpc-api.js'; diff --git a/src/lib/http-server-shutdown-handler.ts b/src/lib/http-server-shutdown-handler.ts index f97fc68..aad41a5 100644 --- a/src/lib/http-server-shutdown-handler.ts +++ b/src/lib/http-server-shutdown-handler.ts @@ -58,8 +58,8 @@ export class HttpServerShutdownHandler { // Stops the server from accepting new connections and keeps existing connections. This function is asynchronous, // the server is finally closed when all connections are ended and the server emits a 'close' event. - // The optional callback will be called once the 'close' event occurs. Unlike that event, it will be - // called with an Error as its only argument if the server was not open when it was closed. + // The optional callback will be called once the 'close' event occurs. + // The callback will be called with an Error as its only argument if the server was not open when close is called. this.server.close(() => { this.tcpSocketId = 0; this.stopping = false; diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index 5a19503..7a86b2b 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -11,12 +11,18 @@ export class ProofOfWorkManager { public static readonly defaultMaximumAllowedHashValue = '000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; // Challenge nonces that can be used for proof-of-work. - private challengeNonces: { currentChallengeNonce: string, previousChallengeNonce?: string }; + private challengeNonces: { + previousChallengeNonce?: string, + currentChallengeNonce: string, + nextChallengeNonce?: string + }; // There is opportunity to improve implementation here. // TODO: https://github.com/TBD54566975/dwn-server/issues/101 private proofOfWorkOfLastMinute: Map = new Map(); // proofOfWorkId -> timestamp of proof-of-work + // Seed to generate the challenge nonce from, this allows all DWN instances in a cluster to generate the same challenge. + private challengeSeed?: string; private difficultyIncreaseMultiplier: number; private currentMaximumAllowedHashValueAsBigInt: bigint; private initialMaximumAllowedHashValueAsBigInt: bigint; @@ -50,11 +56,13 @@ export class ProofOfWorkManager { desiredSolveCountPerMinute: number, initialMaximumAllowedHashValue: string, difficultyIncreaseMultiplier: number, + challengeSeed?: string, challengeRefreshFrequencyInSeconds: number, difficultyReevaluationFrequencyInSeconds: number }) { const { desiredSolveCountPerMinute, initialMaximumAllowedHashValue } = input; + this.challengeSeed = input.challengeSeed; this.challengeNonces = { currentChallengeNonce: ProofOfWork.generateNonce() }; this.currentMaximumAllowedHashValueAsBigInt = BigInt(`0x${initialMaximumAllowedHashValue}`); this.initialMaximumAllowedHashValueAsBigInt = BigInt(`0x${initialMaximumAllowedHashValue}`); @@ -77,6 +85,7 @@ export class ProofOfWorkManager { autoStart: boolean, initialMaximumAllowedHashValue?: string, difficultyIncreaseMultiplier?: number, + challengeSeed?: string, challengeRefreshFrequencyInSeconds?: number, difficultyReevaluationFrequencyInSeconds?: number }): Promise { @@ -91,6 +100,7 @@ export class ProofOfWorkManager { desiredSolveCountPerMinute, initialMaximumAllowedHashValue, difficultyIncreaseMultiplier, + challengeSeed: input.challengeSeed, challengeRefreshFrequencyInSeconds, difficultyReevaluationFrequencyInSeconds }); @@ -143,8 +153,9 @@ export class ProofOfWorkManager { } // Verify challenge nonce is valid. - if (challengeNonce !== this.challengeNonces.currentChallengeNonce && - challengeNonce !== this.challengeNonces.previousChallengeNonce) { + const { previousChallengeNonce, currentChallengeNonce, nextChallengeNonce } = this.challengeNonces; + const acceptableChallengeNonces = [previousChallengeNonce, currentChallengeNonce, nextChallengeNonce].filter(nonce => nonce !== undefined && nonce !== ''); + if (!acceptableChallengeNonces.includes(challengeNonce)) { throw new DwnServerError( DwnServerErrorCode.ProofOfWorkManagerInvalidChallengeNonce, `Unknown or expired challenge nonce: ${challengeNonce}.` @@ -195,8 +206,23 @@ export class ProofOfWorkManager { } private refreshChallengeNonce(): void { - this.challengeNonces.previousChallengeNonce = this.challengeNonces.currentChallengeNonce; - this.challengeNonces.currentChallengeNonce = ProofOfWork.generateNonce(); + // If challenge seed is supplied, use it to deterministically generate the challenge nonces. + if (this.challengeSeed !== undefined) { + const currentRefreshIntervalId = Math.floor(Date.now() / (this.challengeRefreshFrequencyInSeconds * 1000)); + const previousRefreshIntervalId = currentRefreshIntervalId - 1; + const nextRefreshIntervalId = currentRefreshIntervalId + 1; + + const previousChallengeNonce = ProofOfWork.hashAsHexString([this.challengeSeed, previousRefreshIntervalId.toString(), this.challengeSeed]); + const currentChallengeNonce = ProofOfWork.hashAsHexString([this.challengeSeed, currentRefreshIntervalId.toString(), this.challengeSeed]); + const nextChallengeNonce = ProofOfWork.hashAsHexString([this.challengeSeed, nextRefreshIntervalId.toString(), this.challengeSeed]); + + this.challengeNonces = { previousChallengeNonce, currentChallengeNonce, nextChallengeNonce }; + } else { + const newChallengeNonce = ProofOfWork.generateNonce(); + + this.challengeNonces.previousChallengeNonce = this.challengeNonces.currentChallengeNonce; + this.challengeNonces.currentChallengeNonce = newChallengeNonce; + } } /** diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts index 45a4732..d7c221e 100644 --- a/src/registration/registration-manager.ts +++ b/src/registration/registration-manager.ts @@ -50,9 +50,10 @@ export class RegistrationManager implements TenantGate { public static async create(input: { registrationStoreUrl?: string, termsOfServiceFilePath?: string - initialMaximumAllowedHashValue?: string, + proofOfWorkChallengeNonceSeed?: string, + proofOfWorkInitialMaximumAllowedHash?: string, }): Promise { - const { termsOfServiceFilePath, registrationStoreUrl, initialMaximumAllowedHashValue } = input; + const { termsOfServiceFilePath, registrationStoreUrl } = input; const registrationManager = new RegistrationManager(); @@ -71,7 +72,8 @@ export class RegistrationManager implements TenantGate { registrationManager.proofOfWorkManager = await ProofOfWorkManager.create({ autoStart: true, desiredSolveCountPerMinute: 10, - initialMaximumAllowedHashValue, + initialMaximumAllowedHashValue: input.proofOfWorkInitialMaximumAllowedHash, + challengeSeed: input.proofOfWorkChallengeNonceSeed, }); // Initialize RegistrationStore. diff --git a/src/storage.ts b/src/storage.ts index 1b051f9..1988349 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -27,7 +27,7 @@ import { createPool as MySQLCreatePool } from 'mysql2'; import pg from 'pg'; import Cursor from 'pg-cursor'; -import type { Config } from './config.js'; +import type { DwnServerConfig } from './config.js'; export enum EStoreType { DataStore, @@ -45,7 +45,7 @@ export enum BackendTypes { export type StoreType = DataStore | EventLog | MessageStore; export function getDWNConfig( - config: Config, + config: DwnServerConfig, tenantGate: TenantGate, ): DwnConfig { const dataStore: DataStore = getStore(config.dataStore, EStoreType.DataStore); diff --git a/tests/dwn-server.spec.ts b/tests/dwn-server.spec.ts index 3197b91..b3e4df4 100644 --- a/tests/dwn-server.spec.ts +++ b/tests/dwn-server.spec.ts @@ -1,33 +1,38 @@ import { expect } from 'chai'; +import type { DwnServerConfig } from '../src/config.js'; import { config } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; +import { randomBytes } from 'crypto'; describe('DwnServer', function () { - let dwnServer: DwnServer; + const dwnServerConfig = { ...config }; + before(async function () { - dwnServer = new DwnServer({ config }); + // NOTE: using SQL to workaround an issue where multiple instances of DwnServer can be started using LevelDB in the same test run, + // and dwn-server.spec.ts already uses LevelDB. + dwnServerConfig.messageStore = 'sqlite://'; + dwnServerConfig.dataStore = 'sqlite://'; + dwnServerConfig.eventLog = 'sqlite://'; }); after(async function () { - dwnServer.stop(() => console.log('server stop')); }); - it('should create an instance of DwnServer', function () { - expect(dwnServer).to.be.an.instanceOf(DwnServer); - }); + it('should initialize ProofOfWorkManager with challenge nonce seed if given.', async function () { + const registrationProofOfWorkSeed = randomBytes(32).toString('hex'); + const configWithProofOfWorkSeed: DwnServerConfig = { + ...dwnServerConfig, + registrationStoreUrl: 'sqlite://', + registrationProofOfWorkEnabled: true, + registrationProofOfWorkSeed + }; - it('should start the server and listen on the specified port', async function () { + const dwnServer = new DwnServer({ config: configWithProofOfWorkSeed }); await dwnServer.start(); - const response = await fetch('http://localhost:3000', { - method: 'GET', - }); - expect(response.status).to.equal(200); - }); + expect(dwnServer.registrationManager['proofOfWorkManager']['challengeSeed']).to.equal(registrationProofOfWorkSeed); - it('should stop the server', async function () { dwnServer.stop(() => console.log('server Stop')); - // Add an assertion to check that the server has been stopped expect(dwnServer.httpServer.listening).to.be.false; }); }); diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 0656704..f16a869 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -59,8 +59,8 @@ describe('http api', function () { // RegistrationManager creation const registrationStoreUrl = config.registrationStoreUrl; const termsOfServiceFilePath = config.termsOfServiceFilePath; - const initialMaximumAllowedHashValue = config.registrationProofOfWorkInitialMaxHash; - registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath, initialMaximumAllowedHashValue }); + const proofOfWorkInitialMaximumAllowedHash = config.registrationProofOfWorkInitialMaxHash; + registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath, proofOfWorkInitialMaximumAllowedHash }); dwn = await getTestDwn(registrationManager); diff --git a/tests/registration/proof-of-work-manager.spec.ts b/tests/registration/proof-of-work-manager.spec.ts index 8dde4ae..06354a2 100644 --- a/tests/registration/proof-of-work-manager.spec.ts +++ b/tests/registration/proof-of-work-manager.spec.ts @@ -6,6 +6,8 @@ import { expect } from 'chai'; import { useFakeTimers } from 'sinon'; import { v4 as uuidv4 } from 'uuid'; import { ProofOfWorkManager } from '../..//src/registration/proof-of-work-manager.js'; +import { randomBytes } from 'crypto'; +import { ProofOfWork } from '../../src/registration/proof-of-work.js'; describe('ProofOfWorkManager', function () { let clock; @@ -59,6 +61,49 @@ describe('ProofOfWorkManager', function () { expect(maximumAllowedHashValueRefreshSpy.callCount).to.greaterThanOrEqual(expectedDifficultyReevaluationCount); }); + it('should accept previous and next challenge nonce to account for server time drift when challenge seed is given.', async function () { + const desiredSolveCountPerMinute = 10; + const initialMaximumAllowedHashValue = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; // always accept + const challengeSeed = randomBytes(32).toString('hex'); + const proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: true, + desiredSolveCountPerMinute, + challengeSeed, + initialMaximumAllowedHashValue, + }); + + const previousChallengeNonce = proofOfWorkManager['challengeNonces'].previousChallengeNonce; + const nextChallengeNonce = proofOfWorkManager['challengeNonces'].nextChallengeNonce; + expect(previousChallengeNonce?.length).to.equal(64); + expect(nextChallengeNonce?.length).to.equal(64); + + const requestData = 'irrelevant'; + + // Expect to accept response nonce generated using previous challenge nonce. + const responseNonceUsingPreviousChallengeNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce: previousChallengeNonce, + maximumAllowedHashValue: initialMaximumAllowedHashValue, + requestData + }); + await proofOfWorkManager.verifyProofOfWork({ + challengeNonce: previousChallengeNonce, + responseNonce: responseNonceUsingPreviousChallengeNonce, + requestData + }); + + // Expect to accept response nonce generated using next challenge nonce. + const responseNonceUsingNextChallengeNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce: nextChallengeNonce, + maximumAllowedHashValue: initialMaximumAllowedHashValue, + requestData + }); + await proofOfWorkManager.verifyProofOfWork({ + challengeNonce: nextChallengeNonce, + responseNonce: responseNonceUsingNextChallengeNonce, + requestData + }); + }); + it('should increase difficulty if proof-of-work rate goes above desired rate and reduce difficulty as proof-of-work rate falls below desired rate.', async function () { const desiredSolveCountPerMinute = 10; const initialMaximumAllowedHashValue = 'FFFFFFFF'; diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts index ed5d1ee..fc16080 100644 --- a/tests/scenarios/registration.spec.ts +++ b/tests/scenarios/registration.spec.ts @@ -377,12 +377,15 @@ describe('Registration scenarios', function () { expect(registrationResponseBody2.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerInvalidResponseNonceFormat); }); - it('should reject a registration request that uses an expired challenge nonce', async () => { + it('should reject a response nonce based on an expired challenge nonce and accept one is based on the new challenge nonce', async () => { // Scenario: // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. // 1. A long time has passed since Alice fetched the proof-of-work challenge and the challenge nonce has expired. // 2. Alice computes the proof-of-work response nonce based on the the proof-of-work challenge and the registration data. // 3. Alice sends the registration request to the server and it is rejected. + // 4. Alice fetches the new proof-of-work challenge. + // 5. Alice computes the proof-of-work response nonce based on the the new proof-of-work challenge and the registration data. + // 6. Alice sends the new registration request to the server and it is accepted. // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. const termsOfService = registrationManager.getTermsOfService(); @@ -421,6 +424,39 @@ describe('Registration scenarios', function () { const registrationResponseBody = await registrationResponse.json() as any; expect(registrationResponse.status).to.equal(400); expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerInvalidChallengeNonce); + + // 4. Alice fetches the new proof-of-work challenge. + const proofOfWorkChallengeGetResponse = await fetch(proofOfWorkEndpoint, { + method: 'GET', + }); + const { + challengeNonce: newChallengeNonce, + maximumAllowedHashValue: newMaximumAllowedHashValue + } = await proofOfWorkChallengeGetResponse.json() as ProofOfWorkChallengeModel; + expect(proofOfWorkChallengeGetResponse.status).to.equal(200); + + // 5. Alice computes the proof-of-work response nonce based on the the new proof-of-work challenge and the registration data. + const newResponseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce: newChallengeNonce, + maximumAllowedHashValue: newMaximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + // 6. Alice sends the new registration request to the server and it is accepted. + const newRegistrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce: newChallengeNonce, + responseNonce: newResponseNonce + }, + }; + + const newRegistrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newRegistrationRequest), + }); + expect(newRegistrationResponse.status).to.equal(200); }); it('should reject a DWN message for an existing tenant who agreed to an outdated terms-of-service.', async () => {