Skip to content

Commit

Permalink
Added support for synchronized proof-of-work challenge nonce across n…
Browse files Browse the repository at this point in the history
…odes (#103)

Added support for synchronized proof-of-work challenge nonce across
nodes by:
- Added a seed for proof-of-work to allow deterministic challenge nonce
generation.
- Allowed for slight server clock drift.

With this PR, the registration endpoint with proof-of-work is now ready
to be used in a production environment with many nodes.
  • Loading branch information
thehenrytsai authored Jan 23, 2024
1 parent c4a46c0 commit f16e915
Show file tree
Hide file tree
Showing 13 changed files with 155 additions and 38 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions src/dwn-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 3 additions & 3 deletions src/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 2 additions & 2 deletions src/lib/http-server-shutdown-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 31 additions & 5 deletions src/registration/proof-of-work-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = 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;
Expand Down Expand Up @@ -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}`);
Expand All @@ -77,6 +85,7 @@ export class ProofOfWorkManager {
autoStart: boolean,
initialMaximumAllowedHashValue?: string,
difficultyIncreaseMultiplier?: number,
challengeSeed?: string,
challengeRefreshFrequencyInSeconds?: number,
difficultyReevaluationFrequencyInSeconds?: number
}): Promise<ProofOfWorkManager> {
Expand All @@ -91,6 +100,7 @@ export class ProofOfWorkManager {
desiredSolveCountPerMinute,
initialMaximumAllowedHashValue,
difficultyIncreaseMultiplier,
challengeSeed: input.challengeSeed,
challengeRefreshFrequencyInSeconds,
difficultyReevaluationFrequencyInSeconds
});
Expand Down Expand Up @@ -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}.`
Expand Down Expand Up @@ -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;
}
}

/**
Expand Down
8 changes: 5 additions & 3 deletions src/registration/registration-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegistrationManager> {
const { termsOfServiceFilePath, registrationStoreUrl, initialMaximumAllowedHashValue } = input;
const { termsOfServiceFilePath, registrationStoreUrl } = input;

const registrationManager = new RegistrationManager();

Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
33 changes: 19 additions & 14 deletions tests/dwn-server.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
4 changes: 2 additions & 2 deletions tests/http-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
45 changes: 45 additions & 0 deletions tests/registration/proof-of-work-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down
Loading

0 comments on commit f16e915

Please sign in to comment.