From 1bd5a91b64e118845e8410aac87d47735f4361e8 Mon Sep 17 00:00:00 2001 From: finn Date: Tue, 31 Oct 2023 11:57:38 -0700 Subject: [PATCH 01/27] Add optional (disabled by default) support for requiring new DIDs to complete some tasks before they may user the server. proof of work challenge and terms of service challenges are currently available --- README.md | 44 +- package-lock.json | 55 ++- package.json | 2 + src/config.ts | 9 + src/dwn-server.ts | 25 +- src/http-api.ts | 40 +- src/storage.ts | 12 +- src/tenant-gate.ts | 262 ++++++++++++ tests/cors.spec.ts | 16 +- tests/dwn-process-message.spec.ts | 8 +- tests/dwn-server.spec.ts | 12 +- tests/fixtures/tos.txt | 39 ++ tests/http-api.spec.ts | 665 ++++++++++++++++++++++++------ tests/process-handler.spec.ts | 12 +- tests/test-dwn.ts | 56 ++- tests/utils.ts | 26 ++ tests/ws-api.spec.ts | 11 +- 17 files changed, 1090 insertions(+), 204 deletions(-) create mode 100644 src/tenant-gate.ts create mode 100644 tests/fixtures/tos.txt diff --git a/README.md b/README.md index 0782fe1..16050e2 100644 --- a/README.md +++ b/README.md @@ -277,15 +277,18 @@ cloudflared tunnel --url http://localhost:3000 Configuration can be set using environment variables -| Env Var | Description | Default | -| ------------------------- | -------------------------------------------------------------------------------------- | ---------------------- | -| `DS_PORT` | Port that the server listens on | `3000` | -| `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_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | -| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | -| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | -| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | +| Env Var | Description | Default | +| -------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- | +| `DS_PORT` | Port that the server listens on | `3000` | +| `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_POW` | require new users to complete a proof-of-work challenge | `false` | +| `DWN_REGISTRATION_TOS` | require users to agree to a terms of service. Value is path to the terms of service file. | unset | +| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | +| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | +| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_REGISTRATION` | URL to use for storage of registered DIDs | `sqlite://data/dwn.db` | ### Storage Options @@ -297,3 +300,26 @@ Several storage formats are supported, and may be configured with the `DWN_STORA | Sqlite | `sqlite://dwn.db` | use three slashes for absolute paths, two for relative. Example shown creates a file `dwn.db` in the current working directory | | MySQL | `mysql://user:pass@host/db?debug=true&timezone=-0700` | [all URL options documented here](https://github.com/mysqljs/mysql#connection-options) | | PostgreSQL | `postgres:///dwn` | any options other than the URL scheme (`postgres://`) may also be specified via [standard environment variables](https://node-postgres.com/features/connecting#environment-variables) | + +## Registration Requirements + +There are multiple optional registration gates, each of which can be enabled (all are disabled by default). Tenants (DIDs) must comply with whatever +requirements are enabled before they are allowed to use the server. Tenants that have not completed the registration requirements will be met with a 401. Note that registration is tracked in a database, and only SQL-based databases are supported (LevelDB is not supported). Current registration +requirements are available at the `/info.json` endpoint. + +- **Proof of Work** (`DWN_REGISTRATION_POW=true`) - new tenants must GET `/register/pow` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/register/pow` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info.json` as `proof-of-work-sha256-v0`. +- **Terms of Service** (`DWN_REGISTRATION_TOS=/path/to/tos.txt`) - new tenants must GET `/register/tos` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/register/tos`. The JSON body should have fields `tosHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info.json` as `terms-of-service`. + +## Server info + +the server exposes information about itself via the `/info.json` endpoint, which returns data in the following format: + +```json +{ + "server": "@web5/dwn-server", + "maxFileSize": 1073741824, + "registrationRequirements": ["proof-of-work-sha256-v0", "terms-of-service"], + "version": "0.1.5", + "sdkVersion": "0.2.6" +} +``` diff --git a/package-lock.json b/package-lock.json index 6c6a4dd..13e2544 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "@tbd54566975/dwn-sdk-js": "0.2.6", "@tbd54566975/dwn-sql-store": "0.2.2", "better-sqlite3": "^8.5.0", + "body-parser": "^1.20.2", "bytes": "3.1.2", "cors": "2.8.5", "express": "4.18.2", + "kysely": "^0.26.3", "loglevel": "^1.8.1", "loglevel-plugin-prefix": "^0.8.4", "multiformats": "11.0.2", @@ -2130,12 +2132,12 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -2143,7 +2145,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -4358,6 +4360,29 @@ "node": ">= 0.10.0" } }, + "node_modules/express/node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -4371,6 +4396,20 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/express/node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/express/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -8353,9 +8392,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", diff --git a/package.json b/package.json index 0e897b2..11c4da0 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,11 @@ "@tbd54566975/dwn-sdk-js": "0.2.6", "@tbd54566975/dwn-sql-store": "0.2.2", "better-sqlite3": "^8.5.0", + "body-parser": "^1.20.2", "bytes": "3.1.2", "cors": "2.8.5", "express": "4.18.2", + "kysely": "^0.26.3", "loglevel": "^1.8.1", "loglevel-plugin-prefix": "^0.8.4", "multiformats": "11.0.2", diff --git a/src/config.ts b/src/config.ts index 4e52380..38c7a1e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,15 @@ export const config = { eventLog: process.env.DWN_STORAGE_EVENTS || process.env.DWN_STORAGE || 'level://data', + // require POW-based registration for new tenants + registrationRequirementPow: process.env.DWN_REGISTRATION_POW == 'true', + tenantRegistrationStore: + process.env.DWN_STORAGE_REGISTRATION || + process.env.DWN_STORAGE || + 'sqlite://data/dwn.db', + + registrationRequirementTos: process.env.DWN_REGISTRATION_TOS, + // log level - trace/debug/info/warn/error logLevel: process.env.DWN_SERVER_LOG_LEVEL || 'INFO', }; diff --git a/src/dwn-server.ts b/src/dwn-server.ts index c54c2a3..8c800c2 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -1,5 +1,6 @@ import { Dwn } from '@tbd54566975/dwn-sdk-js'; +import { readFileSync } from 'fs'; import type { Server } from 'http'; import log from 'loglevel'; import prefix from 'loglevel-plugin-prefix'; @@ -10,7 +11,8 @@ import { HttpServerShutdownHandler } from './lib/http-server-shutdown-handler.js import { type Config, config as defaultConfig } from './config.js'; import { HttpApi } from './http-api.js'; import { setProcessHandlers } from './process-handlers.js'; -import { getDWNConfig } from './storage.js'; +import { getDWNConfig, getDialectFromURI } from './storage.js'; +import { TenantGate } from './tenant-gate.js'; import { WsApi } from './ws-api.js'; export type DwnServerOptions = { @@ -46,12 +48,27 @@ export class DwnServer { * The DWN creation is secondary and only happens if it hasn't already been done. */ async #setupServer(): Promise { + let tenantGate: TenantGate; if (!this.dwn) { - this.dwn = await Dwn.create(getDWNConfig(this.config)); + const tenantGateDB = getDialectFromURI( + new URL(this.config.tenantRegistrationStore), + ); + const tos = + this.config.registrationRequirementTos !== undefined + ? readFileSync(this.config.registrationRequirementTos).toString() + : null; + tenantGate = new TenantGate( + tenantGateDB, + this.config.registrationRequirementPow, + this.config.registrationRequirementTos !== undefined, + tos, + ); + + this.dwn = await Dwn.create(getDWNConfig(this.config, tenantGate)); } - this.#httpApi = new HttpApi(this.dwn); - this.#httpApi.start(this.config.port, () => { + this.#httpApi = new HttpApi(this.dwn, tenantGate); + await this.#httpApi.start(this.config.port, () => { log.info(`HttpServer listening on port ${this.config.port}`); }); diff --git a/src/http-api.ts b/src/http-api.ts index d40d90e..5147b96 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -7,6 +7,7 @@ import { import cors from 'cors'; import type { Express, Request, Response } from 'express'; import express from 'express'; +import { readFileSync } from 'fs'; import http from 'http'; import log from 'loglevel'; import { register } from 'prom-client'; @@ -20,18 +21,26 @@ import { JsonRpcErrorCodes, } from './lib/json-rpc.js'; +import { config } from './config.js'; import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter, responseHistogram } from './metrics.js'; +import type { TenantGate } from './tenant-gate.js'; + +const packagejson = process.env.npm_package_json + ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) + : {}; export class HttpApi { #api: Express; #server: http.Server; + tenantGate: TenantGate; dwn: Dwn; - constructor(dwn: Dwn) { + constructor(dwn: Dwn, tenantGate: TenantGate) { this.#api = express(); this.#server = http.createServer(this.#api); this.dwn = dwn; + this.tenantGate = tenantGate; this.#setupMiddleware(); this.#setupRoutes(); @@ -47,6 +56,7 @@ export class HttpApi { #setupMiddleware(): void { this.#api.use(cors({ exposedHeaders: 'dwn-response' })); + this.#api.use(express.json()); this.#api.use( responseTime((req: Request, res: Response, time) => { @@ -181,13 +191,39 @@ export class HttpApi { return res.json(jsonRpcResponse); } }); + + if (this.tenantGate) { + this.tenantGate.setupRoutes(this.#api); + } + + this.#api.get('/info.json', (req, res) => { + res.setHeader('content-type', 'application/json'); + const registrationRequirements: string[] = []; + if (config.registrationRequirementPow) { + registrationRequirements.push('proof-of-work-sha256-v0'); + } + if (config.registrationRequirementTos) { + registrationRequirements.push('terms-of-service'); + } + + res.json({ + server: process.env.npm_package_name, + maxFileSize: config.maxRecordDataSize, + registrationRequirements: registrationRequirements, + version: packagejson.version, + sdkVersion: packagejson.dependencies['@tbd54566975/dwn-sdk-js'], + }); + }); } #listen(port: number, callback?: () => void): void { this.#server.listen(port, callback); } - start(port: number, callback?: () => void): http.Server { + async start(port: number, callback?: () => void): Promise { + if (this.tenantGate) { + await this.tenantGate.initialize(); + } this.#listen(port, callback); return this.#server; } diff --git a/src/storage.ts b/src/storage.ts index 6719bd6..3cc5ca6 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -25,6 +25,7 @@ import pg from 'pg'; import Cursor from 'pg-cursor'; import type { Config } from './config.js'; +import type { TenantGate } from './tenant-gate.js'; export enum EStoreType { DataStore, @@ -41,7 +42,10 @@ export enum BackendTypes { export type StoreType = DataStore | EventLog | MessageStore; -export function getDWNConfig(config: Config): DwnConfig { +export function getDWNConfig( + config: Config, + tenantGate: TenantGate, +): DwnConfig { const dataStore: DataStore = getStore(config.dataStore, EStoreType.DataStore); const eventLog: EventLog = getStore(config.eventLog, EStoreType.EventLog); const messageStore: MessageStore = getStore( @@ -49,7 +53,7 @@ export function getDWNConfig(config: Config): DwnConfig { EStoreType.MessageStore, ); - return { eventLog, dataStore, messageStore }; + return { eventLog, dataStore, messageStore, tenantGate }; } function getLevelStore( @@ -113,14 +117,14 @@ function getStore(storeString: string, storeType: EStoreType): StoreType { case BackendTypes.SQLITE: case BackendTypes.MYSQL: case BackendTypes.POSTGRES: - return getDBStore(getDBFromURI(storeURI), storeType); + return getDBStore(getDialectFromURI(storeURI), storeType); default: throw invalidStorageSchemeMessage(storeURI.protocol); } } -function getDBFromURI(u: URL): Dialect { +export function getDialectFromURI(u: URL): Dialect { switch (u.protocol.slice(0, -1)) { case BackendTypes.SQLITE: return new SqliteDialect({ diff --git a/src/tenant-gate.ts b/src/tenant-gate.ts new file mode 100644 index 0000000..4aacb6b --- /dev/null +++ b/src/tenant-gate.ts @@ -0,0 +1,262 @@ +import { createHash, randomBytes } from 'crypto'; +import type { Request, Response } from 'express'; +import type { Express } from 'express'; +import type { Dialect } from 'kysely'; +import { Kysely } from 'kysely'; + +const recentChallenges: { [challenge: string]: number } = {}; +const CHALLENGE_TIMEOUT = 5 * 60 * 1000; // challenges are valid this long after issuance +const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this timeframe +const COMPLEXITY_MINIMUM = 5; + +export class TenantGate { + #db: Kysely; + #powRequired: boolean; + #tosRequired: boolean; + #tos?: string; + #tosHash?: string; + #logRejections: boolean; + + constructor( + dialect: Dialect, + powRequired: boolean, + tosRequired: boolean, + currentTOS?: string, + logRejections?: boolean, + ) { + this.#db = new Kysely({ dialect: dialect }); + this.#powRequired = powRequired; + this.#tosRequired = tosRequired; + if (tosRequired) { + this.#tos = currentTOS; + const tosHash = createHash('sha256'); + tosHash.update(currentTOS); + this.#tosHash = tosHash.digest('hex'); + } + this.#logRejections = logRejections || false; + } + + async initialize(): Promise { + setInterval(() => { + for (const challenge of Object.keys(recentChallenges)) { + if ( + recentChallenges[challenge] && + Date.now() - recentChallenges[challenge] > CHALLENGE_TIMEOUT + ) { + delete recentChallenges[challenge]; + } + } + }, CHALLENGE_TIMEOUT / 4); + + await this.#db.schema + .createTable('authorizedTenants') + .ifNotExists() + .addColumn('did', 'text', (column) => column.primaryKey()) + .addColumn('powTime', 'timestamp') + .addColumn('tos', 'boolean') + .execute(); + } + + setupRoutes(server: Express): void { + if (this.#powRequired) { + server.get('/register/pow', (req: Request, res: Response) => + this.getProofOfWorkChallenge(req, res), + ); + server.post('/register/pow', (req: Request, res: Response) => + this.verifyProofOfWorkChallenge(req, res), + ); + } + if (this.#tosRequired) { + server.get('/register/tos', (req: Request, res: Response) => + res.send(this.#tos), + ); + server.post('/register/tos', (req: Request, res: Response) => + this.acceptTOS(req, res), + ); + } + } + + async isTenant(tenant: string): Promise { + if (!this.#powRequired && !this.#tosRequired) { + return true; + } + + const result = await this.#db + .selectFrom('authorizedTenants') + .select('powTime') + .select('tos') + .where('did', '=', tenant) + .execute(); + + if (result.length == 0) { + console.log('rejecting tenant that is not in the database', { tenant }); + return false; + } + + const row = result[0]; + + if (this.#powRequired && row.powTime == undefined) { + console.log('rejecting tenant that has not completed the proof of work', { + tenant, + }); + return false; + } + + if (this.#tosRequired && row.tos != this.#tosHash) { + console.log( + 'rejecting tenant that has not accepted the current terms of service', + { row, tenant, expected: this.#tosHash }, + ); + return false; + } + + return true; + } + + async authorizeTenantPOW(tenant: string): Promise { + await this.#db + .insertInto('authorizedTenants') + .values({ + did: tenant, + powTime: Date.now(), + }) + .onConflict((oc) => + oc.column('did').doUpdateSet((eb) => ({ + powTime: eb.ref('excluded.powTime'), + })), + ) + .executeTakeFirst(); + } + + private async getProofOfWorkChallenge( + _req: Request, + res: Response, + ): Promise { + const challenge = randomBytes(10).toString('base64'); + recentChallenges[challenge] = Date.now(); + res.json({ + challenge: challenge, + complexity: await this.getComplexity(), + }); + } + + private async verifyProofOfWorkChallenge( + req: Request, + res: Response, + ): Promise { + const body: { + did: string; + challenge: string; + response: string; + } = req.body; + + const challengeIssued = recentChallenges[body.challenge]; + if ( + challengeIssued == undefined || + Date.now() - challengeIssued > CHALLENGE_TIMEOUT + ) { + res + .status(401) + .json({ success: false, reason: 'challenge invalid or expired' }); + return; + } + + const hash = createHash('sha256'); + hash.update(body.challenge); + hash.update(body.response); + + const complexity = await this.getComplexity(); + const digest = hash.digest('hex'); + if (!digest.startsWith('0'.repeat(complexity))) { + res.status(401).json({ + success: false, + reason: 'insufficiently complex', + requiredComplexity: complexity, + }); + return; + } + + try { + await this.authorizeTenantPOW(body.did); + } catch (e) { + console.log('error inserting did', e); + res.status(500).json({ success: false }); + return; + } + res.json({ success: true }); + } + + private async getComplexity(): Promise { + const result = await this.#db + .selectFrom('authorizedTenants') + .where('powTime', '>', Date.now() - COMPLEXITY_LOOKBACK) + .select((eb) => eb.fn.countAll().as('recent_reg_count')) + .executeTakeFirstOrThrow(); + const recent = result.recent_reg_count as number; + if (recent == 0) { + return COMPLEXITY_MINIMUM; + } + + const complexity = Math.floor(recent / 10); + if (complexity < COMPLEXITY_MINIMUM) { + return COMPLEXITY_MINIMUM; + } + + return complexity; + } + + private async acceptTOS(req: Request, res: Response): Promise { + const body: { + did: string; + tosHash: string; + } = req.body; + + if (body.tosHash != this.#tosHash) { + res.status(400).json({ + success: false, + reason: 'incorrect TOS hash', + }); + } + + console.log('accepting tos', body); + + await this.#db + .insertInto('authorizedTenants') + .values({ + did: body.did, + tos: body.tosHash, + }) + .onConflict((oc) => + oc.column('did').doUpdateSet((eb) => ({ + tos: eb.ref('excluded.tos'), + })), + ) + .executeTakeFirstOrThrow(); + res.status(200).json({ success: true }); + } + + async authorizeTenantTOS(tenant: string): Promise { + await this.#db + .insertInto('authorizedTenants') + .values({ + did: tenant, + tos: this.#tosHash, + }) + .onConflict((oc) => + oc.column('did').doUpdateSet((eb) => ({ + tos: eb.ref('excluded.tos'), + })), + ) + .executeTakeFirst(); + } +} + +interface AuthorizedTenants { + did: string; + tos: string; + powTime: number; +} + +interface TenantRegistrationDatabase { + authorizedTenants: AuthorizedTenants; +} diff --git a/tests/cors.spec.ts b/tests/cors.spec.ts index d55d571..fda1078 100644 --- a/tests/cors.spec.ts +++ b/tests/cors.spec.ts @@ -7,7 +7,7 @@ import { executablePath } from 'puppeteer'; import { config as defaultConfig } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; let noBrowser; try { @@ -24,19 +24,18 @@ class CorsProxySetup { proxyPort = 9875; public async start(): Promise { + const testdwn = await getTestDwn(); const dwnServer = new DwnServer({ - dwn: dwn, + dwn: testdwn.dwn, config: { ...defaultConfig, port: 0, // UNSPEC to obtain test specific free port + registrationRequirementPow: false, }, }); - const dwnPort = await new Promise((resolve) => { - dwnServer.start(() => { - const port = (dwnServer.httpServer.address() as AddressInfo).port; - resolve(port); - }); - }); + await dwnServer.start(); + const dwnPort = (dwnServer.httpServer.address() as AddressInfo).port; + // setup proxy server const proxy = httpProxy.createProxyServer({}); const server = http.createServer((req, res) => { @@ -77,7 +76,6 @@ class CorsProxySetup { await new Promise((resolve) => { dwnServer.stop(resolve); }); - await clearDwn(); } } diff --git a/tests/dwn-process-message.spec.ts b/tests/dwn-process-message.spec.ts index e6da39e..346db67 100644 --- a/tests/dwn-process-message.spec.ts +++ b/tests/dwn-process-message.spec.ts @@ -4,14 +4,10 @@ import { v4 as uuidv4 } from 'uuid'; import { handleDwnProcessMessage } from '../src/json-rpc-handlers/dwn/process-message.js'; import type { RequestContext } from '../src/lib/json-rpc-router.js'; import { createJsonRpcRequest } from '../src/lib/json-rpc.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; import { createProfile, createRecordsWriteMessage } from './utils.js'; describe('handleDwnProcessMessage', function () { - afterEach(async function () { - await clearDwn(); - }); - it('returns a JSON RPC Success Response when DWN returns a 2XX status code', async function () { const alice = await createProfile(); @@ -23,6 +19,7 @@ describe('handleDwnProcessMessage', function () { target: alice.did, }); + const dwn = (await getTestDwn()).dwn; const context: RequestContext = { dwn, transport: 'http', dataStream }; const { jsonRpcResponse } = await handleDwnProcessMessage( @@ -47,6 +44,7 @@ describe('handleDwnProcessMessage', function () { target: 'did:key:abc1234', }); + const dwn = (await getTestDwn()).dwn; const context: RequestContext = { dwn, transport: 'http' }; const { jsonRpcResponse } = await handleDwnProcessMessage( diff --git a/tests/dwn-server.spec.ts b/tests/dwn-server.spec.ts index da17745..8dc94e1 100644 --- a/tests/dwn-server.spec.ts +++ b/tests/dwn-server.spec.ts @@ -2,20 +2,16 @@ import { expect } from 'chai'; import { config } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { clear, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; describe('DwnServer', function () { let dwnServer: DwnServer; - const options = { - dwn: dwn, - config: config, - }; - before(function () { - dwnServer = new DwnServer(options); + before(async function () { + const testdwn = await getTestDwn(); + dwnServer = new DwnServer({ dwn: testdwn.dwn, config: config }); }); after(async function () { dwnServer.stop(() => console.log('server stop')); - await clear(); }); it('should create an instance of DwnServer', function () { expect(dwnServer).to.be.an.instanceOf(DwnServer); diff --git a/tests/fixtures/tos.txt b/tests/fixtures/tos.txt new file mode 100644 index 0000000..bfbe414 --- /dev/null +++ b/tests/fixtures/tos.txt @@ -0,0 +1,39 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla eget efficitur lorem. Duis vel viverra urna. In eget lobortis arcu. Interdum et malesuada fames ac ante ipsum primis in faucibus. Morbi aliquet purus non lacus scelerisque pellentesque. Nullam tempus arcu auctor nisi placerat cursus. Nunc sed odio sit amet dui eleifend iaculis. Sed enim augue, suscipit non metus eu, vehicula maximus enim. Sed auctor rhoncus tortor ac commodo. Nulla suscipit justo vel purus faucibus varius. Vestibulum mollis, libero vel scelerisque maximus, justo diam laoreet est, elementum suscipit nulla massa quis odio. Nam at consequat ipsum, in rhoncus diam. Suspendisse mattis augue id luctus tincidunt. Vivamus fringilla nisl imperdiet ligula tincidunt eleifend. + +Nam et convallis ipsum. Aenean cursus porta rutrum. Nam efficitur a risus ut gravida. Nulla viverra molestie porta. Suspendisse et risus vitae ante hendrerit tempus. Mauris iaculis magna eros, ac lacinia nisl elementum non. Suspendisse ultrices, libero quis faucibus facilisis, purus dolor aliquet nibh, vel scelerisque dolor ante maximus nulla. Aenean nec porta nisi. Nulla suscipit augue sit amet enim eleifend gravida. Quisque tristique finibus mattis. Quisque faucibus eros id nisl lobortis, at rhoncus dui ullamcorper. Phasellus vel risus malesuada, molestie elit eu, condimentum erat. Ut vel elit eu elit pellentesque luctus. Duis venenatis vehicula nisi, in iaculis mi eleifend at. Quisque arcu velit, suscipit in urna sit amet, ullamcorper malesuada ipsum. Donec maximus orci eget tellus blandit, sed tincidunt ante scelerisque. + +Curabitur rhoncus egestas consequat. Nunc sed ex turpis. Aliquam rhoncus fringilla arcu id dictum. Nullam dignissim lorem non lectus tempor porttitor. Donec nec nisl nec enim pulvinar tristique. Pellentesque tincidunt sem quis ex varius, quis viverra eros semper. Donec tristique tortor et odio placerat, a auctor nunc interdum. Fusce ultricies ut orci nec viverra. Sed a ex purus. Nunc id dolor eu ante posuere commodo. + +Nullam neque magna, maximus sit amet luctus nec, laoreet ac arcu. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam semper posuere laoreet. Curabitur orci velit, venenatis non metus eu, semper varius mi. Quisque non leo non quam molestie commodo ac lobortis turpis. Vestibulum rhoncus iaculis leo, eu tincidunt purus pretium ut. Cras eleifend metus sit amet mi suscipit consequat vel vitae diam. Ut iaculis ullamcorper leo in tincidunt. + +Morbi sem ex, vehicula ut augue et, interdum placerat quam. Sed ac ligula nulla. Ut rhoncus dapibus ipsum, sit amet condimentum turpis fermentum ut. Sed in nulla at ipsum vulputate tincidunt vel in ante. Donec nec suscipit nunc. Ut ultricies sem quis metus finibus pharetra. Vestibulum vestibulum nibh augue, ut pellentesque nisi congue at. Etiam pretium dolor ac fringilla cursus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vestibulum posuere placerat pharetra. Vestibulum tempus massa ac nulla pretium, id gravida felis luctus. Sed venenatis sollicitudin odio. Phasellus pellentesque ornare semper. Aenean libero turpis, varius et sapien sed, cursus laoreet lacus. Integer diam tortor, placerat interdum nunc quis, hendrerit tincidunt massa. + +Suspendisse et lacus elit. Nunc finibus dolor eget mattis lacinia. Fusce ac libero orci. Vestibulum gravida ligula eget sem venenatis fermentum. Fusce auctor volutpat est a dignissim. Nam eu mollis quam, in imperdiet mi. Mauris nec purus turpis. Nam volutpat metus ac eros eleifend malesuada. Aenean ut erat non lectus suscipit fringilla ac vel ipsum. Donec non mauris quis sem iaculis facilisis. Mauris convallis orci rutrum elit maximus imperdiet. + +Integer pellentesque non diam aliquet semper. Mauris ornare, quam vel condimentum pulvinar, purus quam rhoncus ipsum, sed congue dolor lorem in lectus. Pellentesque sed congue sapien. Aenean vitae lectus mollis, molestie purus vitae, tristique diam. Cras tristique consequat orci sit amet laoreet. Donec porta, risus sed fringilla tincidunt, dui magna mattis mauris, sed dignissim libero magna semper velit. Nullam enim tortor, interdum ac lacus eget, pulvinar volutpat libero. Mauris auctor lacinia tortor. Fusce at dolor sit amet dui pellentesque facilisis at ut nisi. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Praesent dictum risus eget enim bibendum, sed cursus orci faucibus. + +Pellentesque pulvinar, massa ac tincidunt consequat, enim lacus varius turpis, ut pellentesque nulla nulla ac leo. Pellentesque ac tincidunt neque, sit amet tempor mi. Phasellus imperdiet ornare lacus. Nam viverra vel ligula sed gravida. Aliquam in orci scelerisque, malesuada lorem tristique, hendrerit urna. In vel lacus tortor. Quisque ornare sem a orci convallis interdum. Aenean maximus laoreet velit sed laoreet. Aenean pellentesque a quam imperdiet sagittis. Sed et ultrices arcu, eget molestie lacus. Nullam cursus eros id metus porttitor ullamcorper. Nulla mattis dui ac nibh varius dapibus. Vestibulum quis pharetra ipsum. Morbi cursus sagittis nunc vitae luctus. Suspendisse gravida lacinia diam, ac venenatis justo varius eget. Sed sodales erat justo, ac tincidunt ante commodo eu. + +Quisque elit massa, commodo eget rhoncus sit amet, porta vel nunc. Vestibulum id leo leo. Aenean ac est ut justo tincidunt mattis quis sed leo. Nunc libero turpis, congue ut ligula sed, laoreet cursus mi. Pellentesque blandit eget est vel porta. Pellentesque malesuada, magna eu vulputate pharetra, nulla odio mollis nisl, vel rhoncus dui dolor volutpat elit. Aliquam tincidunt ultricies massa, vel finibus turpis aliquet ut. Nunc id nulla et risus pretium blandit. Pellentesque blandit rutrum ornare. Ut id venenatis urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. In maximus, tellus mollis dictum malesuada, lacus lectus blandit orci, id varius tellus libero non nisi. Sed maximus pellentesque vulputate. Praesent placerat, ligula vitae semper feugiat, mi quam finibus lacus, quis maximus tortor ex id lectus. Morbi dapibus eget orci tristique efficitur. Etiam dui magna, efficitur quis venenatis ac, suscipit eu orci. + +Aliquam turpis mi, luctus ut auctor vel, finibus sed neque. Integer eleifend sit amet justo vehicula sollicitudin. Aenean nec rutrum magna. In bibendum turpis ullamcorper, commodo metus nec, eleifend neque. Vivamus a interdum massa, ac tincidunt nibh. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque imperdiet, magna ac volutpat mattis, velit lacus rhoncus nisl, quis vehicula justo purus vel eros. Morbi ut dapibus justo, in cursus mauris. Curabitur imperdiet iaculis convallis. Ut mauris diam, venenatis non convallis et, euismod sed ex. Mauris tellus sapien, fermentum a mollis eu, sagittis non mi. Nunc ac vulputate purus. Phasellus orci tellus, interdum sit amet nunc in, vehicula convallis lacus. Curabitur nisl orci, gravida ut turpis a, bibendum molestie eros. + +Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Ut tempor vitae sapien ac sagittis. Vivamus imperdiet, est non venenatis blandit, justo tortor porttitor purus, nec pulvinar lectus ligula ut enim. Donec non erat id velit maximus imperdiet vel vitae quam. Duis eu efficitur massa. Fusce nec sem scelerisque, egestas lectus id, lacinia nunc. Vestibulum sollicitudin consectetur lectus. Donec tempor interdum faucibus. Morbi eget eleifend arcu. + +Phasellus ut auctor elit. Vivamus pretium nulla a bibendum rutrum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed justo odio, egestas quis felis vel, mollis pretium mauris. Integer lacinia odio porta justo auctor lacinia. Quisque faucibus eu nisi vel imperdiet. Curabitur et urna orci. Quisque vestibulum interdum ligula, ut rutrum mauris gravida id. Pellentesque iaculis mi in laoreet egestas. Donec lobortis facilisis eros, vel ultricies odio luctus vel. Curabitur in leo nunc. Donec hendrerit risus vitae augue hendrerit lobortis. In scelerisque tempus nunc, eu pellentesque lacus. + +Nunc nunc purus, suscipit non dignissim ac, cursus vitae metus. Nulla fringilla leo in libero mollis posuere. Phasellus ornare dignissim risus, at efficitur nisl porta et. Donec mollis fringilla massa, sed venenatis libero vehicula id. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed condimentum turpis ipsum. Ut consectetur turpis orci, sed tincidunt quam maximus nec. Curabitur fermentum nisi in dolor elementum aliquam. Integer dictum pharetra lectus at malesuada. Nunc tincidunt nunc ac bibendum semper. In fringilla purus ex, vel egestas justo vehicula nec. Cras non justo leo. Fusce posuere sapien eget felis dictum semper. Donec hendrerit condimentum magna vel faucibus. + +Maecenas tempor auctor augue, vitae lobortis lacus gravida sit amet. Nam ac mi nec sem ultricies luctus in et odio. Cras nibh nulla, finibus non ex et, accumsan euismod libero. Cras pretium ex sit amet sem luctus, sed ultrices enim posuere. Donec faucibus, mauris eu lacinia congue, lectus velit cursus est, ac vestibulum metus dolor eget nisl. Morbi varius quam a sem ullamcorper malesuada. Maecenas velit nibh, accumsan a eleifend ut, iaculis quis magna. Nam ante tortor, venenatis et dignissim sit amet, ornare eget mi. Morbi nec dui nisi. + +Integer scelerisque dictum lectus, sit amet feugiat eros pharetra a. Donec scelerisque nibh eget mi venenatis, a bibendum tortor rhoncus. Sed eleifend sit amet quam vitae ullamcorper. Integer libero mi, imperdiet eu ornare vitae, eleifend et orci. Nullam a rutrum dui. Donec eu finibus purus, eu lacinia nunc. Donec nec velit a massa ullamcorper ultrices a a augue. In hac habitasse platea dictumst. Morbi euismod purus at ipsum sollicitudin ullamcorper. Sed interdum dolor vel feugiat consequat. Praesent ac urna malesuada, bibendum turpis suscipit, blandit lacus. Maecenas placerat nisl facilisis pretium dignissim. + +Nunc nec sapien malesuada, malesuada nibh vitae, fringilla libero. Aliquam lobortis hendrerit leo, ut dignissim orci. Nunc ut dui nec tellus imperdiet bibendum. In vitae diam et urna luctus viverra in sit amet dui. Nulla ligula dui, laoreet non tempus nec, feugiat sit amet dolor. In condimentum posuere urna, at iaculis enim mollis eu. Ut consectetur odio at congue vulputate. Maecenas iaculis, ex sed fermentum mollis, neque urna pulvinar tellus, ac porttitor lacus dui ut est. Aliquam erat volutpat. Morbi vel tellus eu purus blandit blandit vitae vel elit. Sed est erat, sodales a sollicitudin at, semper vel dui. Pellentesque posuere nibh erat, ac finibus purus mattis id. Aliquam sagittis varius enim, id dignissim mauris hendrerit ut. + +Nam in posuere ligula. Pellentesque vehicula vulputate libero, ac porta risus mollis in. Aenean euismod nunc ut nisl interdum elementum. Fusce interdum imperdiet imperdiet. Donec sed massa vitae nunc iaculis rutrum. Quisque scelerisque commodo congue. Aenean porttitor elementum dolor, in accumsan elit pretium sit amet. + +Maecenas vitae est eget ex venenatis posuere. Fusce elementum turpis nec maximus semper. Nunc ultricies pulvinar mauris, vel rhoncus lorem molestie vitae. Sed sit amet facilisis urna. Cras ultrices lacus nec porttitor euismod. Ut elementum scelerisque tempor. Phasellus id elit viverra, tincidunt ex eget, vestibulum ligula. Integer non purus purus. Vestibulum dignissim posuere ligula ut vulputate. + +Fusce pretium felis in ullamcorper egestas. Sed sit amet gravida metus. Donec hendrerit tellus nec elit hendrerit, a tempor urna mattis. Donec mattis lobortis nulla, sit amet facilisis arcu euismod in. Nunc eget porttitor orci, a varius augue. Aliquam et aliquet eros, ac euismod mauris. Ut ac lacus est. Integer neque odio, efficitur vel tempus id, placerat quis magna. Donec a quam turpis. Ut eget mi maximus, fermentum mauris a, auctor massa. + +Quisque a tempus eros. Suspendisse mi felis, feugiat vestibulum bibendum vel, pellentesque sit amet risus. Nam commodo, elit molestie suscipit tristique, diam magna blandit nulla, ut tincidunt dui enim efficitur est. Curabitur rhoncus justo at lorem scelerisque porttitor. Aliquam vitae egestas nibh. Morbi at magna porttitor, cursus leo quis, hendrerit dui. Nulla accumsan libero ac lobortis bibendum. Pellentesque vel velit nunc. Duis volutpat posuere tellus ut tincidunt. \ No newline at end of file diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index b9e878f..6070a5d 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -5,14 +5,19 @@ import { RecordsQuery, RecordsRead, } from '@tbd54566975/dwn-sdk-js'; +import type { Dwn } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; import type { Server } from 'http'; import fetch from 'node-fetch'; import { webcrypto } from 'node:crypto'; +import { useFakeTimers } from 'sinon'; import request from 'supertest'; import { v4 as uuidv4 } from 'uuid'; +import { config } from '../src/config.js'; import { HttpApi } from '../src/http-api.js'; import type { JsonRpcErrorResponse, @@ -22,12 +27,16 @@ import { createJsonRpcRequest, JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import type { TenantGate } from '../src/tenant-gate.js'; +import { getTestDwn } from './test-dwn.js'; +import type { Profile } from './utils.js'; import { createProfile, createRecordsWriteMessage, getFileAsReadStream, streamHttpRequest, + checkNonce, + generateNonce, } from './utils.js'; if (!globalThis.crypto) { @@ -38,126 +47,527 @@ if (!globalThis.crypto) { describe('http api', function () { let httpApi: HttpApi; let server: Server; + let profile: Profile; + let tenantGate: TenantGate; + let dwn: Dwn; + let clock; before(async function () { - httpApi = new HttpApi(dwn); + clock = useFakeTimers({ shouldAdvanceTime: true }); + + config.registrationRequirementPow = true; + config.registrationRequirementTos = './tests/fixtures/tos.txt'; + const testdwn = await getTestDwn(true, true); + dwn = testdwn.dwn; + tenantGate = testdwn.tenantGate; + + httpApi = new HttpApi(dwn, tenantGate); + + await tenantGate.initialize(); + profile = await createProfile(); + await tenantGate.authorizeTenantPOW(profile.did); + await tenantGate.authorizeTenantTOS(profile.did); }); beforeEach(async function () { - server = httpApi.start(3000); + server = await httpApi.start(3000); }); afterEach(async function () { server.close(); server.closeAllConnections(); - await clearDwn(); }); - it('responds with a 400 if no dwn-request header is provided', async function () { - const response = await request(httpApi.api).post('/').send(); + after(function () { + clock.restore(); + }); - expect(response.statusCode).to.equal(400); + describe('/register/pow', function () { + it('returns a register challenge', async function () { + const response = await fetch('http://localhost:3000/register/pow'); + expect(response.status).to.equal(200); + const body = (await response.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + expect(body.complexity).to.equal(5); + }); - const body = response.body as JsonRpcErrorResponse; - expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); - expect(body.error.message).to.equal('request payload required.'); - }); + it('accepts a correct registration challenge', async function () { + const challengeResponse = await fetch( + 'http://localhost:3000/register/pow', + ); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + expect(body.complexity).to.equal(5); + + // solve the challenge + let response = ''; + while (!checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + } + + const p = await createProfile(); + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: p.did, + }), + }); - it('responds with a 400 if parsing dwn request fails', async function () { - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', ';;;;@!#@!$$#!@%') - .send(); + expect(submitResponse.status).to.equal(200); - expect(response.statusCode).to.equal(400); + await tenantGate.authorizeTenantTOS(p.did); - const body = response.body as JsonRpcErrorResponse; - expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); - expect(body.error.message).to.include('JSON'); - }); + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: p.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: p.did, + }); + + const rpcResponse = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + console.log(rpcResponse.body.result.reply.status); + expect(rpcResponse.statusCode).to.equal(200); + expect(rpcResponse.body.id).to.equal(requestId); + expect(rpcResponse.body.result.reply.status.code).to.equal(200); + }).timeout(30000); + + it('rejects a registration challenge 5 minutes after it was issued', async function () { + const challengeResponse = await fetch( + 'http://localhost:3000/register/pow', + ); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + expect(body.complexity).to.equal(5); + + clock.tick(5 * 60 * 1000); + clock.runToLast(); + + // solve the challenge + let response = ''; + while (!checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + } + + const p = await createProfile(); + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: p.did, + }), + }); + + expect(submitResponse.status).to.equal(401); + }).timeout(30000); + + it('increase complexity as more challenges are completed', async function () { + for (let i = 1; i <= 60; i++) { + tenantGate.authorizeTenantPOW((await createProfile()).did); + } + + const p = await createProfile(); + const challengeResponse = await fetch( + 'http://localhost:3000/register/pow', + ); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + + // solve the challenge + let response = ''; + let iterations = 0; + const start = Date.now(); + while (!checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + iterations++; + if (iterations % 10000000 == 0) { + console.log( + 'complexity:', + body.complexity, + 'iteration count:', + iterations, + 'duration:', + Date.now() - start, + 'ms', + ); + } + } + + console.log( + 'complexity:', + body.complexity, + 'iteration count:', + iterations, + 'duration:', + Date.now() - start, + 'ms', + ); + + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: p.did, + }), + }); + + expect(submitResponse.status).to.equal(200); + }).timeout(120000); + + it('rejects an invalid nonce', async function () { + const challengeResponse = await fetch( + 'http://localhost:3000/register/pow', + ); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(16); + + // generate a nonce + let response = generateNonce(5); + // make sure the nonce is INVALID + // loop continues until checkNonce returns false, which is will probably do on the first iteration + while (checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + } + + const p = await createProfile(); + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: p.did, + }), + }); + + expect(submitResponse.status).to.equal(401); + }); - it('responds with a 2XX HTTP status if JSON RPC handler returns 4XX/5XX DWN status code', async function () { - const alice = await createProfile(); - const { recordsWrite, dataStream } = await createRecordsWriteMessage(alice); + it('rejects a challenge it did not issue', async function () { + const challenge = generateNonce(10); - // Intentionally delete a required property to produce an invalid RecordsWrite message. - const message = recordsWrite.toJSON(); - delete message['descriptor']['interface']; + // solve the challenge + let response = ''; + while (!checkNonce(challenge, response, 2)) { + response = generateNonce(5); + } - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: message, - target: alice.did, + const p = await createProfile(); + const submitResponse = await fetch('http://localhost:3000/register/pow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: challenge, + response: response, + did: p.did, + }), + }); + + expect(submitResponse.status).to.equal(401); }); - const dataBytes = await DataStream.toBytes(dataStream); + it('rejects tenants that have not accepted the TOS and have not completed POW', async function () { + const unauthorized = await createProfile(); + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: unauthorized.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: unauthorized.did, + }); + + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); - // Attempt an initial RecordsWrite with the invalid message to ensure the DWN returns an error. - const responseInitialWrite = await fetch('http://localhost:3000', { - method: 'POST', - headers: { - 'dwn-request': JSON.stringify(dwnRequest), - }, - body: new Blob([dataBytes]), + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.result.reply.status.code).to.equal(401); }); - expect(responseInitialWrite.status).to.equal(200); + it('rejects tenants that have accepted the TOS but not completed POW', async function () { + const unauthorized = await createProfile(); + await tenantGate.authorizeTenantTOS(unauthorized.did); + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: unauthorized.signer, + }); - const body = (await responseInitialWrite.json()) as JsonRpcResponse; - expect(body.id).to.equal(requestId); - expect(body.error).to.not.exist; + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: unauthorized.did, + }); - const { reply } = body.result; - expect(reply.status.code).to.equal(400); - expect(reply.status.detail).to.include( - 'Both interface and method must be present', - ); + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.result.reply.status.code).to.equal(401); + }); }); - it('exposes dwn-response header', async function () { - // This test verifies that the Express web server includes `dwn-response` in the list of - // `access-control-expose-headers` returned in each HTTP response. This is necessary to enable applications - // that have CORS enabled to read and parse DWeb Messages that are returned as Response headers, particularly - // in the case of RecordsRead messages. + describe('/register/tos', function () { + it('allow tenant that after accepting the terms of service', async function () { + const response = await fetch('http://localhost:3000/register/tos'); + expect(response.status).to.equal(200); + + const terms = await response.text(); - // TODO: github.com/TBD54566975/dwn-server/issues/50 - // Consider replacing this test with a more robust method of testing, such as writing Playwright tests - // that run in a browser to verify that the `dwn-response` header can be read from the `fetch()` response - // when CORS mode is enabled. - const response = await request(httpApi.api).post('/').send(); + expect(terms).to.equal( + readFileSync('./tests/fixtures/tos.txt').toString(), + ); + + const hash = createHash('sha256'); + hash.update(terms); + + const p = await createProfile(); + + const acceptResponse = await fetch('http://localhost:3000/register/tos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: p.did, + tosHash: hash.digest('hex'), + }), + }); + expect(acceptResponse.status).to.equal(200); + await tenantGate.authorizeTenantPOW(p.did); + + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: p.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: p.did, + }); - // Check if the 'access-control-expose-headers' header is present - expect(response.headers).to.have.property('access-control-expose-headers'); + const rpcResponse = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + console.log(rpcResponse.body.result.reply.status); + expect(rpcResponse.statusCode).to.equal(200); + expect(rpcResponse.body.id).to.equal(requestId); + expect(rpcResponse.body.result.reply.status.code).to.equal(200); + }); - // Check if the 'dwn-response' header is listed in 'access-control-expose-headers' - const exposedHeaders = response.headers['access-control-expose-headers']; - expect(exposedHeaders).to.include('dwn-response'); + it('rejects tenants that have completed POW but have not accepted the TOS', async function () { + const unauthorized = await createProfile(); + await tenantGate.authorizeTenantPOW(unauthorized.did); + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: unauthorized.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: unauthorized.did, + }); + + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.result.reply.status.code).to.equal(401); + }); + + it('rejects TOS acceptance with incorrect hash', async function () { + const hash = createHash('sha256'); + hash.update('i do not agree'); + + const p = await createProfile(); + + const acceptResponse = await fetch('http://localhost:3000/register/tos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: p.did, + tosHash: hash.digest('hex'), + }), + }); + expect(acceptResponse.status).to.equal(400); + await tenantGate.authorizeTenantPOW(p.did); + + const recordsQuery = await RecordsQuery.create({ + filter: { schema: 'woosa' }, + signer: p.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: p.did, + }); + + const rpcResponse = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + console.log(rpcResponse.body.result.reply.status); + expect(rpcResponse.statusCode).to.equal(200); + expect(rpcResponse.body.id).to.equal(requestId); + expect(rpcResponse.body.result.reply.status.code).to.equal(401); + }); }); - it('works fine when no request body is provided', async function () { - const alice = await createProfile(); - const recordsQuery = await RecordsQuery.create({ - filter: { - schema: 'woosa', - }, - signer: alice.signer, + describe('/ (rpc)', function () { + it('responds with a 400 if no dwn-request header is provided', async function () { + const response = await request(httpApi.api).post('/').send(); + + expect(response.statusCode).to.equal(400); + + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.equal('request payload required.'); }); - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: alice.did, + it('responds with a 400 if parsing dwn request fails', async function () { + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', ';;;;@!#@!$$#!@%') + .send(); + + expect(response.statusCode).to.equal(400); + + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.include('JSON'); }); - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); + it('responds with a 2XX HTTP status if JSON RPC handler returns 4XX/5XX DWN status code', async function () { + const { recordsWrite, dataStream } = + await createRecordsWriteMessage(profile); + + // Intentionally delete a required property to produce an invalid RecordsWrite message. + const message = recordsWrite.toJSON(); + delete message['descriptor']['interface']; + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: message, + target: profile.did, + }); + + const dataBytes = await DataStream.toBytes(dataStream); - expect(response.statusCode).to.equal(200); - expect(response.body.id).to.equal(requestId); - expect(response.body.error).to.not.exist; - expect(response.body.result.reply.status.code).to.equal(200); + // Attempt an initial RecordsWrite with the invalid message to ensure the DWN returns an error. + const responseInitialWrite = await fetch('http://localhost:3000', { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(dwnRequest), + }, + body: new Blob([dataBytes]), + }); + + expect(responseInitialWrite.status).to.equal(200); + + const body = (await responseInitialWrite.json()) as JsonRpcResponse; + expect(body.id).to.equal(requestId); + expect(body.error).to.not.exist; + + const { reply } = body.result; + expect(reply.status.code).to.equal(400); + expect(reply.status.detail).to.include( + 'Both interface and method must be present', + ); + }); + + it('exposes dwn-response header', async function () { + // This test verifies that the Express web server includes `dwn-response` in the list of + // `access-control-expose-headers` returned in each HTTP response. This is necessary to enable applications + // that have CORS enabled to read and parse DWeb Messages that are returned as Response headers, particularly + // in the case of RecordsRead messages. + + // TODO: github.com/TBD54566975/dwn-server/issues/50 + // Consider replacing this test with a more robust method of testing, such as writing Playwright tests + // that run in a browser to verify that the `dwn-response` header can be read from the `fetch()` response + // when CORS mode is enabled. + const response = await request(httpApi.api).post('/').send(); + + // Check if the 'access-control-expose-headers' header is present + expect(response.headers).to.have.property( + 'access-control-expose-headers', + ); + + // Check if the 'dwn-response' header is listed in 'access-control-expose-headers' + const exposedHeaders = response.headers['access-control-expose-headers']; + expect(exposedHeaders).to.include('dwn-response'); + }); + + it('works fine when no request body is provided', async function () { + const recordsQuery = await RecordsQuery.create({ + filter: { + schema: 'woosa', + }, + signer: profile.signer, + }); + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: profile.did, + }); + + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.error).to.not.exist; + expect(response.body.result.reply.status.code).to.equal(200); + }); }); describe('RecordsWrite', function () { @@ -165,8 +575,7 @@ describe('http api', function () { const filePath = './fixtures/test.jpeg'; const { cid, size, stream } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: cid, dataSize: size, }); @@ -174,7 +583,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); const resp = await streamHttpRequest( @@ -200,16 +609,18 @@ describe('http api', function () { }); it('handles RecordsWrite overwrite that does not mutate data', async function () { - const alice = await createProfile(); + const p = await createProfile(); + await tenantGate.authorizeTenantPOW(p.did); + await tenantGate.authorizeTenantTOS(p.did); // First RecordsWrite that creates the record. const { recordsWrite: initialWrite, dataStream } = - await createRecordsWriteMessage(alice); + await createRecordsWriteMessage(p); const dataBytes = await DataStream.toBytes(dataStream); let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: initialWrite.toJSON(), - target: alice.did, + target: p.did, }); const responseInitialWrite = await fetch('http://localhost:3000', { @@ -223,21 +634,18 @@ describe('http api', function () { expect(responseInitialWrite.status).to.equal(200); // Subsequent RecordsWrite that mutates the published property of the record. - const { recordsWrite: overWrite } = await createRecordsWriteMessage( - alice, - { - recordId: initialWrite.message.recordId, - dataCid: initialWrite.message.descriptor.dataCid, - dataSize: initialWrite.message.descriptor.dataSize, - dateCreated: initialWrite.message.descriptor.dateCreated, - published: true, - }, - ); + const { recordsWrite: overWrite } = await createRecordsWriteMessage(p, { + recordId: initialWrite.message.recordId, + dataCid: initialWrite.message.descriptor.dataCid, + dataSize: initialWrite.message.descriptor.dataSize, + dateCreated: initialWrite.message.descriptor.dateCreated, + published: true, + }); requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: overWrite.toJSON(), - target: alice.did, + target: p.did, }); const responseOverwrite = await fetch('http://localhost:3000', { method: 'POST', @@ -254,18 +662,18 @@ describe('http api', function () { expect(body.error).to.not.exist; const { reply } = body.result; + console.log(reply); expect(reply.status.code).to.equal(202); }); it('handles a RecordsWrite tombstone', async function () { - const alice = await createProfile(); const { recordsWrite: tombstone } = - await createRecordsWriteMessage(alice); + await createRecordsWriteMessage(profile); const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: tombstone.toJSON(), - target: alice.did, + target: profile.did, }); const responeTombstone = await fetch('http://localhost:3000', { @@ -306,8 +714,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: expectedCid, dataSize: size, }); @@ -315,7 +722,7 @@ describe('http api', function () { let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); let response = await fetch('http://localhost:3000', { @@ -336,7 +743,7 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); const recordsRead = await RecordsRead.create({ - signer: alice.signer, + signer: profile.signer, filter: { recordId: recordsWrite.message.recordId, }, @@ -344,7 +751,7 @@ describe('http api', function () { requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - target: alice.did, + target: profile.did, message: recordsRead.toJSON(), }); @@ -390,8 +797,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: expectedCid, dataSize: size, published: true, @@ -400,7 +806,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); let response = await fetch('http://localhost:3000', { @@ -421,7 +827,7 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); response = await fetch( - `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, ); const blob = await response.blob(); @@ -436,8 +842,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: expectedCid, dataSize: size, }); @@ -445,7 +850,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); let response = await fetch('http://localhost:3000', { @@ -466,38 +871,50 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); response = await fetch( - `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); it('returns a 404 if record doesnt exist', async function () { - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice); + const { recordsWrite } = await createRecordsWriteMessage(profile); const response = await fetch( - `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); - it('returns a 404 for invalid did', async function () { - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice); + it('returns a 404 for invalid or unauthorized did', async function () { + const unauthorized = await createProfile(); + const { recordsWrite } = await createRecordsWriteMessage(unauthorized); const response = await fetch( - `http://localhost:3000/1234567892345678/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${unauthorized.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); it('returns a 404 for invalid record id', async function () { - const alice = await createProfile(); const response = await fetch( - `http://localhost:3000/${alice.did}/records/kaka`, + `http://localhost:3000/${profile.did}/records/kaka`, ); expect(response.status).to.equal(404); }); }); + + describe('/info.json', function () { + it('verify /info.json has some of the fields it is supposed to have', async function () { + const resp = await fetch(`http://localhost:3000/info.json`); + expect(resp.status).to.equal(200); + + const info = await resp.json(); + expect(info['server']).to.equal('@web5/dwn-server'); + expect(info['registrationRequirements']).to.include('terms-of-service'); + expect(info['registrationRequirements']).to.include( + 'proof-of-work-sha256-v0', + ); + }); + }); }); diff --git a/tests/process-handler.spec.ts b/tests/process-handler.spec.ts index caa13c6..ba18a22 100644 --- a/tests/process-handler.spec.ts +++ b/tests/process-handler.spec.ts @@ -3,20 +3,15 @@ import sinon from 'sinon'; import { config } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { clear, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; describe('Process Handlers', function () { let dwnServer: DwnServer; - const options = { - dwn: dwn, - config: config, - }; let processExitStub: sinon.SinonStub; - before(async function () { - dwnServer = new DwnServer(options); - }); beforeEach(async function () { + const testdwn = await getTestDwn(); + dwnServer = new DwnServer({ dwn: testdwn.dwn, config: config }); await dwnServer.start(); processExitStub = sinon.stub(process, 'exit'); }); @@ -24,7 +19,6 @@ describe('Process Handlers', function () { process.removeAllListeners('SIGINT'); process.removeAllListeners('SIGTERM'); process.removeAllListeners('uncaughtException'); - await clear(); processExitStub.restore(); }); it('should stop when SIGINT is emitted', async function () { diff --git a/tests/test-dwn.ts b/tests/test-dwn.ts index 515ece5..f02979e 100644 --- a/tests/test-dwn.ts +++ b/tests/test-dwn.ts @@ -1,21 +1,45 @@ +import { Dwn } from '@tbd54566975/dwn-sdk-js'; import { - Dwn, - DataStoreLevel, - EventLogLevel, - MessageStoreLevel, -} from '@tbd54566975/dwn-sdk-js'; + DataStoreSql, + EventLogSql, + MessageStoreSql, +} from '@tbd54566975/dwn-sql-store'; -const dataStore = new DataStoreLevel({ blockstoreLocation: 'data/DATASTORE' }); -const eventLog = new EventLogLevel({ location: 'data/EVENTLOG' }); -const messageStore = new MessageStoreLevel({ - blockstoreLocation: 'data/MESSAGESTORE', - indexLocation: 'data/INDEX', -}); +import { readFileSync } from 'node:fs'; -export const dwn = await Dwn.create({ eventLog, dataStore, messageStore }); +import { getDialectFromURI } from '../src/storage.js'; +import { TenantGate } from '../src/tenant-gate.js'; -export async function clear(): Promise { - await dataStore.clear(); - await eventLog.clear(); - await messageStore.clear(); +export async function getTestDwn( + powRequired?: boolean, + tosRequired?: boolean, +): Promise<{ + dwn: Dwn; + tenantGate: TenantGate; +}> { + const db = getDialectFromURI(new URL('sqlite://')); + const dataStore = new DataStoreSql(db); + const eventLog = new EventLogSql(db); + const messageStore = new MessageStoreSql(db); + const tenantGate = new TenantGate( + db, + powRequired, + tosRequired, + tosRequired ? readFileSync('./tests/fixtures/tos.txt').toString() : null, + true, + ); + + let dwn: Dwn; + try { + dwn = await Dwn.create({ + eventLog, + dataStore, + messageStore, + tenantGate, + }); + } catch (e) { + throw e; + } + + return { dwn, tenantGate }; } diff --git a/tests/utils.ts b/tests/utils.ts index 703a76c..9991203 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -7,6 +7,7 @@ import { RecordsWrite, } from '@tbd54566975/dwn-sdk-js'; +import { createHash } from 'crypto'; import type { ReadStream } from 'node:fs'; import fs from 'node:fs'; import http from 'node:http'; @@ -188,3 +189,28 @@ export async function sendWsMessage( }; }); } + +const nonceChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +export function generateNonce(size: number): string { + let challenge = ''; + while (challenge.length < size) { + challenge += nonceChars.charAt( + Math.floor(Math.random() * nonceChars.length), + ); + } + return challenge; +} + +export function checkNonce( + challenge: string, + nonce: string, + complexity: number, +): boolean { + const hash = createHash('sha256'); + hash.update(challenge); + hash.update(nonce); + + return hash.digest('hex').startsWith('0'.repeat(complexity)); +} diff --git a/tests/ws-api.spec.ts b/tests/ws-api.spec.ts index 6e836aa..5a49dab 100644 --- a/tests/ws-api.spec.ts +++ b/tests/ws-api.spec.ts @@ -11,7 +11,7 @@ import { JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; import { WsApi } from '../src/ws-api.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; import { createProfile, createRecordsWriteMessage, @@ -26,14 +26,11 @@ describe('websocket api', function () { server = http.createServer(); server.listen(9002, '127.0.0.1'); - const wsApi = new WsApi(server, dwn); + const testdwn = await getTestDwn(); + const wsApi = new WsApi(server, testdwn.dwn); wsServer = wsApi.start(); }); - afterEach(async function () { - await clearDwn(); - }); - after(function () { wsServer.close(); server.close(); @@ -61,6 +58,7 @@ describe('websocket api', function () { it('handles RecordsWrite messages', async function () { const alice = await createProfile(); + const { recordsWrite, dataStream } = await createRecordsWriteMessage(alice); const dataBytes = await DataStream.toBytes(dataStream); const encodedData = base64url.baseEncode(dataBytes); @@ -78,6 +76,7 @@ describe('websocket api', function () { ); const resp = JSON.parse(data.toString()); expect(resp.id).to.equal(requestId); + console.log(resp.error); expect(resp.error).to.not.exist; const { reply } = resp.result; From ef774fd5bd9f58ac9f16e562e7a487f72e6ece78 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Wed, 3 Jan 2024 11:18:32 -0800 Subject: [PATCH 02/27] tos -> terms of use --- README.md | 26 +++--- src/config.ts | 2 +- src/dwn-server.ts | 20 +++-- src/http-api.ts | 14 ++-- ...nant-gate.ts => registered-tenant-gate.ts} | 83 ++++++++++--------- src/storage.ts | 4 +- .../{tos.txt => terms-of-service.txt} | 0 tests/http-api.spec.ts | 74 +++++++++-------- tests/test-dwn.ts | 30 ++----- 9 files changed, 126 insertions(+), 127 deletions(-) rename src/{tenant-gate.ts => registered-tenant-gate.ts} (74%) rename tests/fixtures/{tos.txt => terms-of-service.txt} (100%) diff --git a/README.md b/README.md index 16050e2..93b3ce7 100644 --- a/README.md +++ b/README.md @@ -277,18 +277,18 @@ cloudflared tunnel --url http://localhost:3000 Configuration can be set using environment variables -| Env Var | Description | Default | -| -------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- | -| `DS_PORT` | Port that the server listens on | `3000` | -| `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_POW` | require new users to complete a proof-of-work challenge | `false` | -| `DWN_REGISTRATION_TOS` | require users to agree to a terms of service. Value is path to the terms of service file. | unset | -| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | -| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | -| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | -| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | -| `DWN_STORAGE_REGISTRATION` | URL to use for storage of registered DIDs | `sqlite://data/dwn.db` | +| Env Var | Description | Default | +| -------------------------------- | --------------------------------------------------------------------------------------- | ---------------------- | +| `DS_PORT` | Port that the server listens on | `3000` | +| `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_POW` | Require new users to complete a proof-of-work challenge | `false` | +| `DWN_TERMS_OF_SERVICE_FILE_PATH` | Required terms of service agreement if set. Value is path to the terms of service file. | unset | +| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | +| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | +| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_REGISTRATION` | URL to use for storage of registered DIDs | `sqlite://data/dwn.db` | ### Storage Options @@ -308,7 +308,7 @@ requirements are enabled before they are allowed to use the server. Tenants that requirements are available at the `/info.json` endpoint. - **Proof of Work** (`DWN_REGISTRATION_POW=true`) - new tenants must GET `/register/pow` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/register/pow` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info.json` as `proof-of-work-sha256-v0`. -- **Terms of Service** (`DWN_REGISTRATION_TOS=/path/to/tos.txt`) - new tenants must GET `/register/tos` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/register/tos`. The JSON body should have fields `tosHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info.json` as `terms-of-service`. +- **Terms of Service** (`DWN_TERMS_OF_SERVICE_FILE_PATH=/path/to/terms-of-service.txt`) - new tenants must GET `/register/terms-of-service` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/register/terms-of-service`. The JSON body should have fields `termsOfServiceHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info.json` as `terms-of-service`. ## Server info diff --git a/src/config.ts b/src/config.ts index 38c7a1e..035a9cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,7 +27,7 @@ export const config = { process.env.DWN_STORAGE || 'sqlite://data/dwn.db', - registrationRequirementTos: process.env.DWN_REGISTRATION_TOS, + termsOfServiceFilePath: process.env.DWN_TERMS_OF_SERVICE_FILE_PATH, // log level - trace/debug/info/warn/error logLevel: process.env.DWN_SERVER_LOG_LEVEL || 'INFO', diff --git a/src/dwn-server.ts b/src/dwn-server.ts index 8c800c2..51af1b8 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -11,8 +11,8 @@ import { HttpServerShutdownHandler } from './lib/http-server-shutdown-handler.js import { type Config, config as defaultConfig } from './config.js'; import { HttpApi } from './http-api.js'; import { setProcessHandlers } from './process-handlers.js'; +import { RegisteredTenantGate } from './registered-tenant-gate.js'; import { getDWNConfig, getDialectFromURI } from './storage.js'; -import { TenantGate } from './tenant-gate.js'; import { WsApi } from './ws-api.js'; export type DwnServerOptions = { @@ -48,20 +48,22 @@ export class DwnServer { * The DWN creation is secondary and only happens if it hasn't already been done. */ async #setupServer(): Promise { - let tenantGate: TenantGate; + let tenantGate: RegisteredTenantGate; if (!this.dwn) { const tenantGateDB = getDialectFromURI( new URL(this.config.tenantRegistrationStore), ); - const tos = - this.config.registrationRequirementTos !== undefined - ? readFileSync(this.config.registrationRequirementTos).toString() - : null; - tenantGate = new TenantGate( + + // Load terms of service if given the path. + const termsOfService = + this.config.termsOfServiceFilePath !== undefined + ? readFileSync(this.config.termsOfServiceFilePath).toString() + : undefined; + + tenantGate = new RegisteredTenantGate( tenantGateDB, this.config.registrationRequirementPow, - this.config.registrationRequirementTos !== undefined, - tos, + termsOfService, ); this.dwn = await Dwn.create(getDWNConfig(this.config, tenantGate)); diff --git a/src/http-api.ts b/src/http-api.ts index 5147b96..98dc3b3 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -24,19 +24,19 @@ import { import { config } from './config.js'; import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter, responseHistogram } from './metrics.js'; -import type { TenantGate } from './tenant-gate.js'; +import type { RegisteredTenantGate } from './registered-tenant-gate.js'; -const packagejson = process.env.npm_package_json +const packageJson = process.env.npm_package_json ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) : {}; export class HttpApi { #api: Express; #server: http.Server; - tenantGate: TenantGate; + tenantGate: RegisteredTenantGate; dwn: Dwn; - constructor(dwn: Dwn, tenantGate: TenantGate) { + constructor(dwn: Dwn, tenantGate: RegisteredTenantGate) { this.#api = express(); this.#server = http.createServer(this.#api); this.dwn = dwn; @@ -202,7 +202,7 @@ export class HttpApi { if (config.registrationRequirementPow) { registrationRequirements.push('proof-of-work-sha256-v0'); } - if (config.registrationRequirementTos) { + if (config.termsOfServiceFilePath !== undefined) { registrationRequirements.push('terms-of-service'); } @@ -210,8 +210,8 @@ export class HttpApi { server: process.env.npm_package_name, maxFileSize: config.maxRecordDataSize, registrationRequirements: registrationRequirements, - version: packagejson.version, - sdkVersion: packagejson.dependencies['@tbd54566975/dwn-sdk-js'], + version: packageJson.version, + sdkVersion: packageJson.dependencies['@tbd54566975/dwn-sdk-js'], }); }); } diff --git a/src/tenant-gate.ts b/src/registered-tenant-gate.ts similarity index 74% rename from src/tenant-gate.ts rename to src/registered-tenant-gate.ts index 4aacb6b..ac98a04 100644 --- a/src/tenant-gate.ts +++ b/src/registered-tenant-gate.ts @@ -9,31 +9,26 @@ const CHALLENGE_TIMEOUT = 5 * 60 * 1000; // challenges are valid this long after const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this timeframe const COMPLEXITY_MINIMUM = 5; -export class TenantGate { +export class RegisteredTenantGate { #db: Kysely; - #powRequired: boolean; - #tosRequired: boolean; - #tos?: string; - #tosHash?: string; - #logRejections: boolean; + #proofOfWorkRequired: boolean; + #termsOfService?: string; + #termsOfServiceHash?: string; constructor( dialect: Dialect, - powRequired: boolean, - tosRequired: boolean, - currentTOS?: string, - logRejections?: boolean, + proofOfWorkRequired: boolean, + termsOfService?: string, ) { this.#db = new Kysely({ dialect: dialect }); - this.#powRequired = powRequired; - this.#tosRequired = tosRequired; - if (tosRequired) { - this.#tos = currentTOS; - const tosHash = createHash('sha256'); - tosHash.update(currentTOS); - this.#tosHash = tosHash.digest('hex'); + this.#proofOfWorkRequired = proofOfWorkRequired; + + if (termsOfService) { + const termsOfServiceHash = createHash('sha256'); + termsOfServiceHash.update(termsOfService); + this.#termsOfServiceHash = termsOfServiceHash.digest('hex'); + this.#termsOfService = termsOfService; } - this.#logRejections = logRejections || false; } async initialize(): Promise { @@ -53,12 +48,12 @@ export class TenantGate { .ifNotExists() .addColumn('did', 'text', (column) => column.primaryKey()) .addColumn('powTime', 'timestamp') - .addColumn('tos', 'boolean') + .addColumn('termsOfServiceHash', 'boolean') .execute(); } setupRoutes(server: Express): void { - if (this.#powRequired) { + if (this.#proofOfWorkRequired) { server.get('/register/pow', (req: Request, res: Response) => this.getProofOfWorkChallenge(req, res), ); @@ -66,25 +61,25 @@ export class TenantGate { this.verifyProofOfWorkChallenge(req, res), ); } - if (this.#tosRequired) { - server.get('/register/tos', (req: Request, res: Response) => - res.send(this.#tos), + if (this.#termsOfService) { + server.get('/register/terms-of-service', (req: Request, res: Response) => + res.send(this.#termsOfService), ); - server.post('/register/tos', (req: Request, res: Response) => - this.acceptTOS(req, res), + server.post('/register/terms-of-service', (req: Request, res: Response) => + this.acceptTermsOfService(req, res), ); } } async isTenant(tenant: string): Promise { - if (!this.#powRequired && !this.#tosRequired) { + if (!this.#proofOfWorkRequired && !this.#termsOfService) { return true; } const result = await this.#db .selectFrom('authorizedTenants') .select('powTime') - .select('tos') + .select('termsOfServiceHash') .where('did', '=', tenant) .execute(); @@ -95,17 +90,20 @@ export class TenantGate { const row = result[0]; - if (this.#powRequired && row.powTime == undefined) { + if (this.#proofOfWorkRequired && row.powTime == undefined) { console.log('rejecting tenant that has not completed the proof of work', { tenant, }); return false; } - if (this.#tosRequired && row.tos != this.#tosHash) { + if ( + this.#termsOfService && + row.termsOfServiceHash != this.#termsOfServiceHash + ) { console.log( 'rejecting tenant that has not accepted the current terms of service', - { row, tenant, expected: this.#tosHash }, + { row, tenant, expected: this.#termsOfServiceHash }, ); return false; } @@ -205,46 +203,49 @@ export class TenantGate { return complexity; } - private async acceptTOS(req: Request, res: Response): Promise { + private async acceptTermsOfService( + req: Request, + res: Response, + ): Promise { const body: { did: string; - tosHash: string; + termsOfServiceHash: string; } = req.body; - if (body.tosHash != this.#tosHash) { + if (body.termsOfServiceHash != this.#termsOfServiceHash) { res.status(400).json({ success: false, - reason: 'incorrect TOS hash', + reason: 'incorrect terms of service hash', }); } - console.log('accepting tos', body); + console.log('accepting terms of service', body); await this.#db .insertInto('authorizedTenants') .values({ did: body.did, - tos: body.tosHash, + termsOfServiceHash: body.termsOfServiceHash, }) .onConflict((oc) => oc.column('did').doUpdateSet((eb) => ({ - tos: eb.ref('excluded.tos'), + termsOfServiceHash: eb.ref('excluded.termsOfServiceHash'), })), ) .executeTakeFirstOrThrow(); res.status(200).json({ success: true }); } - async authorizeTenantTOS(tenant: string): Promise { + async authorizeTenantTermsOfService(tenant: string): Promise { await this.#db .insertInto('authorizedTenants') .values({ did: tenant, - tos: this.#tosHash, + termsOfServiceHash: this.#termsOfServiceHash, }) .onConflict((oc) => oc.column('did').doUpdateSet((eb) => ({ - tos: eb.ref('excluded.tos'), + termsOfServiceHash: eb.ref('excluded.termsOfServiceHash'), })), ) .executeTakeFirst(); @@ -253,7 +254,7 @@ export class TenantGate { interface AuthorizedTenants { did: string; - tos: string; + termsOfServiceHash: string; powTime: number; } diff --git a/src/storage.ts b/src/storage.ts index 3cc5ca6..486b2d7 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -25,7 +25,7 @@ import pg from 'pg'; import Cursor from 'pg-cursor'; import type { Config } from './config.js'; -import type { TenantGate } from './tenant-gate.js'; +import type { RegisteredTenantGate } from './registered-tenant-gate.js'; export enum EStoreType { DataStore, @@ -44,7 +44,7 @@ export type StoreType = DataStore | EventLog | MessageStore; export function getDWNConfig( config: Config, - tenantGate: TenantGate, + tenantGate: RegisteredTenantGate, ): DwnConfig { const dataStore: DataStore = getStore(config.dataStore, EStoreType.DataStore); const eventLog: EventLog = getStore(config.eventLog, EStoreType.EventLog); diff --git a/tests/fixtures/tos.txt b/tests/fixtures/terms-of-service.txt similarity index 100% rename from tests/fixtures/tos.txt rename to tests/fixtures/terms-of-service.txt diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index bbeb7aa..e8e1a9a 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -28,7 +28,7 @@ import { createJsonRpcRequest, JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; -import type { TenantGate } from '../src/tenant-gate.js'; +import type { RegisteredTenantGate } from '../src/registered-tenant-gate.js'; import { getTestDwn } from './test-dwn.js'; import type { Profile } from './utils.js'; import { @@ -49,7 +49,7 @@ describe('http api', function () { let httpApi: HttpApi; let server: Server; let profile: Profile; - let tenantGate: TenantGate; + let tenantGate: RegisteredTenantGate; let dwn: Dwn; let clock; @@ -57,17 +57,17 @@ describe('http api', function () { clock = useFakeTimers({ shouldAdvanceTime: true }); config.registrationRequirementPow = true; - config.registrationRequirementTos = './tests/fixtures/tos.txt'; - const testdwn = await getTestDwn(true, true); - dwn = testdwn.dwn; - tenantGate = testdwn.tenantGate; + config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; + const testDwn = await getTestDwn(true, true); + dwn = testDwn.dwn; + tenantGate = testDwn.tenantGate; httpApi = new HttpApi(dwn, tenantGate); await tenantGate.initialize(); profile = await createProfile(); await tenantGate.authorizeTenantPOW(profile.did); - await tenantGate.authorizeTenantTOS(profile.did); + await tenantGate.authorizeTenantTermsOfService(profile.did); }); beforeEach(async function () { @@ -126,7 +126,7 @@ describe('http api', function () { expect(submitResponse.status).to.equal(200); - await tenantGate.authorizeTenantTOS(p.did); + await tenantGate.authorizeTenantTermsOfService(p.did); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, @@ -300,7 +300,7 @@ describe('http api', function () { expect(submitResponse.status).to.equal(401); }); - it('rejects tenants that have not accepted the TOS and have not completed POW', async function () { + it('rejects tenants that have not accepted the terms of use and have not completed POW', async function () { const unauthorized = await createProfile(); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, @@ -323,9 +323,9 @@ describe('http api', function () { expect(response.body.result.reply.status.code).to.equal(401); }); - it('rejects tenants that have accepted the TOS but not completed POW', async function () { + it('rejects tenants that have accepted the terms of use but not completed POW', async function () { const unauthorized = await createProfile(); - await tenantGate.authorizeTenantTOS(unauthorized.did); + await tenantGate.authorizeTenantTermsOfService(unauthorized.did); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, signer: unauthorized.signer, @@ -348,15 +348,17 @@ describe('http api', function () { }); }); - describe('/register/tos', function () { + describe('/register/terms-of-service', function () { it('allow tenant that after accepting the terms of service', async function () { - const response = await fetch('http://localhost:3000/register/tos'); + const response = await fetch( + 'http://localhost:3000/register/terms-of-service', + ); expect(response.status).to.equal(200); const terms = await response.text(); expect(terms).to.equal( - readFileSync('./tests/fixtures/tos.txt').toString(), + readFileSync('./tests/fixtures/terms-of-service.txt').toString(), ); const hash = createHash('sha256'); @@ -364,14 +366,17 @@ describe('http api', function () { const p = await createProfile(); - const acceptResponse = await fetch('http://localhost:3000/register/tos', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - did: p.did, - tosHash: hash.digest('hex'), - }), - }); + const acceptResponse = await fetch( + 'http://localhost:3000/register/terms-of-service', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: p.did, + termsOfServiceHash: hash.digest('hex'), + }), + }, + ); expect(acceptResponse.status).to.equal(200); await tenantGate.authorizeTenantPOW(p.did); @@ -397,7 +402,7 @@ describe('http api', function () { expect(rpcResponse.body.result.reply.status.code).to.equal(200); }); - it('rejects tenants that have completed POW but have not accepted the TOS', async function () { + it('rejects tenants that have completed POW but have not accepted the terms of use', async function () { const unauthorized = await createProfile(); await tenantGate.authorizeTenantPOW(unauthorized.did); const recordsQuery = await RecordsQuery.create({ @@ -421,20 +426,23 @@ describe('http api', function () { expect(response.body.result.reply.status.code).to.equal(401); }); - it('rejects TOS acceptance with incorrect hash', async function () { + it('rejects terms of use acceptance with incorrect hash', async function () { const hash = createHash('sha256'); hash.update('i do not agree'); const p = await createProfile(); - const acceptResponse = await fetch('http://localhost:3000/register/tos', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - did: p.did, - tosHash: hash.digest('hex'), - }), - }); + const acceptResponse = await fetch( + 'http://localhost:3000/register/terms-of-service', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: p.did, + termsOfServiceHash: hash.digest('hex'), + }), + }, + ); expect(acceptResponse.status).to.equal(400); await tenantGate.authorizeTenantPOW(p.did); @@ -612,7 +620,7 @@ describe('http api', function () { it('handles RecordsWrite overwrite that does not mutate data', async function () { const p = await createProfile(); await tenantGate.authorizeTenantPOW(p.did); - await tenantGate.authorizeTenantTOS(p.did); + await tenantGate.authorizeTenantTermsOfService(p.did); // First RecordsWrite that creates the record. const { recordsWrite: initialWrite, dataStream } = diff --git a/tests/test-dwn.ts b/tests/test-dwn.ts index fbd0376..6b51298 100644 --- a/tests/test-dwn.ts +++ b/tests/test-dwn.ts @@ -7,38 +7,26 @@ import { import { readFileSync } from 'node:fs'; +import { RegisteredTenantGate } from '../src/registered-tenant-gate.js'; import { getDialectFromURI } from '../src/storage.js'; -import { TenantGate } from '../src/tenant-gate.js'; export async function getTestDwn( - powRequired?: boolean, - tosRequired?: boolean, + proofOfWorkRequired?: boolean, + termsOfServiceRequired?: boolean, ): Promise<{ dwn: Dwn; - tenantGate: TenantGate; + tenantGate: RegisteredTenantGate; }> { - // const testDwnDataDirectory = 'data-test'; - // const dataStore = new DataStoreLevel({ - // blockstoreLocation: `${testDwnDataDirectory}/DATASTORE`, - // }); - // const eventLog = new EventLogLevel({ - // location: `${testDwnDataDirectory}/EVENTLOG`, - // }); - // const messageStore = new MessageStoreLevel({ - // blockstoreLocation: `${testDwnDataDirectory}/MESSAGESTORE`, - // indexLocation: `${testDwnDataDirectory}/INDEX`, - // }); - const db = getDialectFromURI(new URL('sqlite://')); const dataStore = new DataStoreSql(db); const eventLog = new EventLogSql(db); const messageStore = new MessageStoreSql(db); - const tenantGate = new TenantGate( + const tenantGate = new RegisteredTenantGate( db, - powRequired, - tosRequired, - tosRequired ? readFileSync('./tests/fixtures/tos.txt').toString() : null, - true, + proofOfWorkRequired, + termsOfServiceRequired + ? readFileSync('./tests/fixtures/terms-of-service.txt').toString() + : undefined, ); let dwn: Dwn; From 0ddefcd2bc05fce22c1d6f3089250f48ccb6ea6f Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Wed, 3 Jan 2024 13:00:39 -0800 Subject: [PATCH 03/27] Added TenantGate reference and minor comments --- src/registered-tenant-gate.ts | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/registered-tenant-gate.ts b/src/registered-tenant-gate.ts index ac98a04..1d7a127 100644 --- a/src/registered-tenant-gate.ts +++ b/src/registered-tenant-gate.ts @@ -1,3 +1,5 @@ +import type { TenantGate } from '@tbd54566975/dwn-sdk-js'; + import { createHash, randomBytes } from 'crypto'; import type { Request, Response } from 'express'; import type { Express } from 'express'; @@ -6,10 +8,10 @@ import { Kysely } from 'kysely'; const recentChallenges: { [challenge: string]: number } = {}; const CHALLENGE_TIMEOUT = 5 * 60 * 1000; // challenges are valid this long after issuance -const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this timeframe +const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this time frame const COMPLEXITY_MINIMUM = 5; -export class RegisteredTenantGate { +export class RegisteredTenantGate implements TenantGate { #db: Kysely; #proofOfWorkRequired: boolean; #termsOfService?: string; @@ -66,7 +68,7 @@ export class RegisteredTenantGate { res.send(this.#termsOfService), ); server.post('/register/terms-of-service', (req: Request, res: Response) => - this.acceptTermsOfService(req, res), + this.handleTermsOfServicePost(req, res), ); } } @@ -203,7 +205,7 @@ export class RegisteredTenantGate { return complexity; } - private async acceptTermsOfService( + private async handleTermsOfServicePost( req: Request, res: Response, ): Promise { @@ -227,12 +229,18 @@ export class RegisteredTenantGate { did: body.did, termsOfServiceHash: body.termsOfServiceHash, }) - .onConflict((oc) => - oc.column('did').doUpdateSet((eb) => ({ - termsOfServiceHash: eb.ref('excluded.termsOfServiceHash'), + // If a row with the same `did` already exists, it updates the `termsOfServiceHash` of the existing row + // to the `termsOfServiceHash` of the row that was attempted to be inserted (`excluded.termsOfServiceHash`). + .onConflict((onConflictBuilder) => + onConflictBuilder.column('did').doUpdateSet((expressionBuilder) => ({ + termsOfServiceHash: expressionBuilder.ref( + 'excluded.termsOfServiceHash', + ), })), ) + // Executes the query. If the query doesn’t affect any rows (ie. if the insert or update didn’t change anything), it throws an error. .executeTakeFirstOrThrow(); + res.status(200).json({ success: true }); } @@ -243,11 +251,14 @@ export class RegisteredTenantGate { did: tenant, termsOfServiceHash: this.#termsOfServiceHash, }) - .onConflict((oc) => - oc.column('did').doUpdateSet((eb) => ({ - termsOfServiceHash: eb.ref('excluded.termsOfServiceHash'), + .onConflict((onConflictBuilder) => + onConflictBuilder.column('did').doUpdateSet((expressionBuilder) => ({ + termsOfServiceHash: expressionBuilder.ref( + 'excluded.termsOfServiceHash', + ), })), ) + // Executes the query. No error is thrown if the query doesn’t affect any rows (ie. if the insert or update didn’t change anything). .executeTakeFirst(); } } From ccce1d4eace7ad88498c076227eaccbe231baaed Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Wed, 3 Jan 2024 16:10:33 -0800 Subject: [PATCH 04/27] pow -> proofOfWork --- README.md | 26 ++++++++--------- src/config.ts | 7 ++--- src/dwn-server.ts | 2 +- src/http-api.ts | 2 +- src/registered-tenant-gate.ts | 22 +++++++------- tests/cors.spec.ts | 2 +- tests/http-api.spec.ts | 54 ++++++++++++++++------------------- 7 files changed, 55 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 93b3ce7..67905d5 100644 --- a/README.md +++ b/README.md @@ -277,18 +277,18 @@ cloudflared tunnel --url http://localhost:3000 Configuration can be set using environment variables -| Env Var | Description | Default | -| -------------------------------- | --------------------------------------------------------------------------------------- | ---------------------- | -| `DS_PORT` | Port that the server listens on | `3000` | -| `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_POW` | Require new users to complete a proof-of-work challenge | `false` | -| `DWN_TERMS_OF_SERVICE_FILE_PATH` | Required terms of service agreement if set. Value is path to the terms of service file. | unset | -| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | -| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | -| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | -| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | -| `DWN_STORAGE_REGISTRATION` | URL to use for storage of registered DIDs | `sqlite://data/dwn.db` | +| Env Var | Description | Default | +| ---------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------- | +| `DS_PORT` | Port that the server listens on | `3000` | +| `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_PROOF_OF_WORK_ENABLED` | Require new users to complete a proof-of-work challenge | `false` | +| `DWN_TERMS_OF_SERVICE_FILE_PATH` | Required terms of service agreement if set. Value is path to the terms of service file. | unset | +| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | +| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | +| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_REGISTRATION` | URL to use for storage of registered DIDs | `sqlite://data/dwn.db` | ### Storage Options @@ -307,7 +307,7 @@ There are multiple optional registration gates, each of which can be enabled (al requirements are enabled before they are allowed to use the server. Tenants that have not completed the registration requirements will be met with a 401. Note that registration is tracked in a database, and only SQL-based databases are supported (LevelDB is not supported). Current registration requirements are available at the `/info.json` endpoint. -- **Proof of Work** (`DWN_REGISTRATION_POW=true`) - new tenants must GET `/register/pow` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/register/pow` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info.json` as `proof-of-work-sha256-v0`. +- **Proof of Work** (`DWN_REGISTRATION_PROOF_OF_WORK_ENABLED=true`) - new tenants must GET `/register/proof-of-work` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/register/proof-of-work` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info.json` as `proof-of-work-sha256-v0`. - **Terms of Service** (`DWN_TERMS_OF_SERVICE_FILE_PATH=/path/to/terms-of-service.txt`) - new tenants must GET `/register/terms-of-service` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/register/terms-of-service`. The JSON body should have fields `termsOfServiceHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info.json` as `terms-of-service`. ## Server info diff --git a/src/config.ts b/src/config.ts index 035a9cf..6891bf5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,7 +10,7 @@ export const config = { // whether to enable 'ws:' webSocketServerEnabled: { on: true, off: false }[process.env.DS_WEBSOCKET_SERVER] ?? true, - // where to store persistant data + // where to store persistent data messageStore: process.env.DWN_STORAGE_MESSAGES || process.env.DWN_STORAGE || @@ -19,9 +19,8 @@ export const config = { process.env.DWN_STORAGE_DATA || process.env.DWN_STORAGE || 'level://data', eventLog: process.env.DWN_STORAGE_EVENTS || process.env.DWN_STORAGE || 'level://data', - - // require POW-based registration for new tenants - registrationRequirementPow: process.env.DWN_REGISTRATION_POW == 'true', + registrationProofOfWorkEnabled: + process.env.DWN_REGISTRATION_PROOF_OF_WORK_ENABLED === 'true', tenantRegistrationStore: process.env.DWN_STORAGE_REGISTRATION || process.env.DWN_STORAGE || diff --git a/src/dwn-server.ts b/src/dwn-server.ts index 51af1b8..59c87c3 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -62,7 +62,7 @@ export class DwnServer { tenantGate = new RegisteredTenantGate( tenantGateDB, - this.config.registrationRequirementPow, + this.config.registrationProofOfWorkEnabled, termsOfService, ); diff --git a/src/http-api.ts b/src/http-api.ts index 98dc3b3..6928a9d 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -199,7 +199,7 @@ export class HttpApi { this.#api.get('/info.json', (req, res) => { res.setHeader('content-type', 'application/json'); const registrationRequirements: string[] = []; - if (config.registrationRequirementPow) { + if (config.registrationProofOfWorkEnabled) { registrationRequirements.push('proof-of-work-sha256-v0'); } if (config.termsOfServiceFilePath !== undefined) { diff --git a/src/registered-tenant-gate.ts b/src/registered-tenant-gate.ts index 1d7a127..87fa2d0 100644 --- a/src/registered-tenant-gate.ts +++ b/src/registered-tenant-gate.ts @@ -49,17 +49,17 @@ export class RegisteredTenantGate implements TenantGate { .createTable('authorizedTenants') .ifNotExists() .addColumn('did', 'text', (column) => column.primaryKey()) - .addColumn('powTime', 'timestamp') + .addColumn('proofOfWorkTime', 'timestamp') .addColumn('termsOfServiceHash', 'boolean') .execute(); } setupRoutes(server: Express): void { if (this.#proofOfWorkRequired) { - server.get('/register/pow', (req: Request, res: Response) => + server.get('/register/proof-of-work', (req: Request, res: Response) => this.getProofOfWorkChallenge(req, res), ); - server.post('/register/pow', (req: Request, res: Response) => + server.post('/register/proof-of-work', (req: Request, res: Response) => this.verifyProofOfWorkChallenge(req, res), ); } @@ -80,7 +80,7 @@ export class RegisteredTenantGate implements TenantGate { const result = await this.#db .selectFrom('authorizedTenants') - .select('powTime') + .select('proofOfWorkTime') .select('termsOfServiceHash') .where('did', '=', tenant) .execute(); @@ -92,7 +92,7 @@ export class RegisteredTenantGate implements TenantGate { const row = result[0]; - if (this.#proofOfWorkRequired && row.powTime == undefined) { + if (this.#proofOfWorkRequired && row.proofOfWorkTime == undefined) { console.log('rejecting tenant that has not completed the proof of work', { tenant, }); @@ -113,16 +113,16 @@ export class RegisteredTenantGate implements TenantGate { return true; } - async authorizeTenantPOW(tenant: string): Promise { + async authorizeTenantProofOfWork(tenant: string): Promise { await this.#db .insertInto('authorizedTenants') .values({ did: tenant, - powTime: Date.now(), + proofOfWorkTime: Date.now(), }) .onConflict((oc) => oc.column('did').doUpdateSet((eb) => ({ - powTime: eb.ref('excluded.powTime'), + proofOfWorkTime: eb.ref('excluded.proofOfWorkTime'), })), ) .executeTakeFirst(); @@ -177,7 +177,7 @@ export class RegisteredTenantGate implements TenantGate { } try { - await this.authorizeTenantPOW(body.did); + await this.authorizeTenantProofOfWork(body.did); } catch (e) { console.log('error inserting did', e); res.status(500).json({ success: false }); @@ -189,7 +189,7 @@ export class RegisteredTenantGate implements TenantGate { private async getComplexity(): Promise { const result = await this.#db .selectFrom('authorizedTenants') - .where('powTime', '>', Date.now() - COMPLEXITY_LOOKBACK) + .where('proofOfWorkTime', '>', Date.now() - COMPLEXITY_LOOKBACK) .select((eb) => eb.fn.countAll().as('recent_reg_count')) .executeTakeFirstOrThrow(); const recent = result.recent_reg_count as number; @@ -266,7 +266,7 @@ export class RegisteredTenantGate implements TenantGate { interface AuthorizedTenants { did: string; termsOfServiceHash: string; - powTime: number; + proofOfWorkTime: number; } interface TenantRegistrationDatabase { diff --git a/tests/cors.spec.ts b/tests/cors.spec.ts index fda1078..88a2719 100644 --- a/tests/cors.spec.ts +++ b/tests/cors.spec.ts @@ -30,7 +30,7 @@ class CorsProxySetup { config: { ...defaultConfig, port: 0, // UNSPEC to obtain test specific free port - registrationRequirementPow: false, + registrationProofOfWorkEnabled: false, }, }); await dwnServer.start(); diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index b8b1e69..a630181 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -56,7 +56,7 @@ describe('http api', function () { before(async function () { clock = useFakeTimers({ shouldAdvanceTime: true }); - config.registrationRequirementPow = true; + config.registrationProofOfWorkEnabled = true; config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; const testDwn = await getTestDwn(true, true); dwn = testDwn.dwn; @@ -66,7 +66,7 @@ describe('http api', function () { await tenantGate.initialize(); profile = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantPOW(profile.did); + await tenantGate.authorizeTenantProofOfWork(profile.did); await tenantGate.authorizeTenantTermsOfService(profile.did); }); @@ -83,9 +83,11 @@ describe('http api', function () { clock.restore(); }); - describe('/register/pow', function () { + describe('/register/proof-of-work', function () { + const proofOfWorkUrl = 'http://localhost:3000/register/proof-of-work'; + it('returns a register challenge', async function () { - const response = await fetch('http://localhost:3000/register/pow'); + const response = await fetch(proofOfWorkUrl); expect(response.status).to.equal(200); const body = (await response.json()) as { challenge: string; @@ -96,9 +98,7 @@ describe('http api', function () { }); it('accepts a correct registration challenge', async function () { - const challengeResponse = await fetch( - 'http://localhost:3000/register/pow', - ); + const challengeResponse = await fetch(proofOfWorkUrl); expect(challengeResponse.status).to.equal(200); const body = (await challengeResponse.json()) as { challenge: string; @@ -114,7 +114,7 @@ describe('http api', function () { } const p = await DidKeyResolver.generate(); - const submitResponse = await fetch('http://localhost:3000/register/pow', { + const submitResponse = await fetch(proofOfWorkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -151,9 +151,7 @@ describe('http api', function () { }).timeout(30000); it('rejects a registration challenge 5 minutes after it was issued', async function () { - const challengeResponse = await fetch( - 'http://localhost:3000/register/pow', - ); + const challengeResponse = await fetch(proofOfWorkUrl); expect(challengeResponse.status).to.equal(200); const body = (await challengeResponse.json()) as { challenge: string; @@ -172,7 +170,7 @@ describe('http api', function () { } const p = await DidKeyResolver.generate(); - const submitResponse = await fetch('http://localhost:3000/register/pow', { + const submitResponse = await fetch(proofOfWorkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -187,13 +185,13 @@ describe('http api', function () { it('increase complexity as more challenges are completed', async function () { for (let i = 1; i <= 60; i++) { - tenantGate.authorizeTenantPOW((await DidKeyResolver.generate()).did); + tenantGate.authorizeTenantProofOfWork( + (await DidKeyResolver.generate()).did, + ); } const p = await DidKeyResolver.generate(); - const challengeResponse = await fetch( - 'http://localhost:3000/register/pow', - ); + const challengeResponse = await fetch(proofOfWorkUrl); expect(challengeResponse.status).to.equal(200); const body = (await challengeResponse.json()) as { challenge: string; @@ -231,7 +229,7 @@ describe('http api', function () { 'ms', ); - const submitResponse = await fetch('http://localhost:3000/register/pow', { + const submitResponse = await fetch(proofOfWorkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -245,9 +243,7 @@ describe('http api', function () { }).timeout(120000); it('rejects an invalid nonce', async function () { - const challengeResponse = await fetch( - 'http://localhost:3000/register/pow', - ); + const challengeResponse = await fetch(proofOfWorkUrl); expect(challengeResponse.status).to.equal(200); const body = (await challengeResponse.json()) as { challenge: string; @@ -264,7 +260,7 @@ describe('http api', function () { } const p = await DidKeyResolver.generate(); - const submitResponse = await fetch('http://localhost:3000/register/pow', { + const submitResponse = await fetch(proofOfWorkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -287,7 +283,7 @@ describe('http api', function () { } const p = await DidKeyResolver.generate(); - const submitResponse = await fetch('http://localhost:3000/register/pow', { + const submitResponse = await fetch(proofOfWorkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -300,7 +296,7 @@ describe('http api', function () { expect(submitResponse.status).to.equal(401); }); - it('rejects tenants that have not accepted the terms of use and have not completed POW', async function () { + it('rejects tenants that have not accepted the terms of use and have not completed proof-of-work', async function () { const unauthorized = await DidKeyResolver.generate(); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, @@ -323,7 +319,7 @@ describe('http api', function () { expect(response.body.result.reply.status.code).to.equal(401); }); - it('rejects tenants that have accepted the terms of use but not completed POW', async function () { + it('rejects tenants that have accepted the terms of use but not completed proof-of-work', async function () { const unauthorized = await DidKeyResolver.generate(); await tenantGate.authorizeTenantTermsOfService(unauthorized.did); const recordsQuery = await RecordsQuery.create({ @@ -378,7 +374,7 @@ describe('http api', function () { }, ); expect(acceptResponse.status).to.equal(200); - await tenantGate.authorizeTenantPOW(p.did); + await tenantGate.authorizeTenantProofOfWork(p.did); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, @@ -402,9 +398,9 @@ describe('http api', function () { expect(rpcResponse.body.result.reply.status.code).to.equal(200); }); - it('rejects tenants that have completed POW but have not accepted the terms of use', async function () { + it('rejects tenants that have completed proof-of-work but have not accepted the terms of use', async function () { const unauthorized = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantPOW(unauthorized.did); + await tenantGate.authorizeTenantProofOfWork(unauthorized.did); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, signer: unauthorized.signer, @@ -444,7 +440,7 @@ describe('http api', function () { }, ); expect(acceptResponse.status).to.equal(400); - await tenantGate.authorizeTenantPOW(p.did); + await tenantGate.authorizeTenantProofOfWork(p.did); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, @@ -619,7 +615,7 @@ describe('http api', function () { it('handles RecordsWrite overwrite that does not mutate data', async function () { const p = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantPOW(p.did); + await tenantGate.authorizeTenantProofOfWork(p.did); await tenantGate.authorizeTenantTermsOfService(p.did); // First RecordsWrite that creates the record. From ae0885ed66916b088b91811a2e6b9586cc9d9878 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Wed, 3 Jan 2024 18:39:19 -0800 Subject: [PATCH 05/27] Introduced ProofOfWork class to encourage code reuse --- src/dwn-error.ts | 20 +++++++ src/registration/proof-of-work.ts | 97 +++++++++++++++++++++++++++++++ tests/http-api.spec.ts | 81 ++++++++------------------ tests/utils.ts | 26 --------- 4 files changed, 141 insertions(+), 83 deletions(-) create mode 100644 src/dwn-error.ts create mode 100644 src/registration/proof-of-work.ts diff --git a/src/dwn-error.ts b/src/dwn-error.ts new file mode 100644 index 0000000..6a55a2e --- /dev/null +++ b/src/dwn-error.ts @@ -0,0 +1,20 @@ +/** + * A class that represents a DWN Server error. + */ +export class DwnServerError extends Error { + constructor( + public code: string, + message: string, + ) { + super(`${code}: ${message}`); + + this.name = 'DwnServerError'; + } +} + +/** + * DWN Server error codes. + */ +export enum DwnServerErrorCode { + ProofOfWorkInsufficientLeadingZeros = 'ProofOfWorkInsufficientLeadingZeros', +} diff --git a/src/registration/proof-of-work.ts b/src/registration/proof-of-work.ts new file mode 100644 index 0000000..984cbbc --- /dev/null +++ b/src/registration/proof-of-work.ts @@ -0,0 +1,97 @@ +import { createHash } from 'crypto'; + +import { DwnServerError, DwnServerErrorCode } from '../dwn-error.js'; + +export class ProofOfWork { + public static computeHash(input: { + challenge: string; + responseNonce: string; + requestData?: string; + }): string { + const hash = createHash('sha256'); + hash.update(input.challenge); + hash.update(input.responseNonce); + + if (input.requestData !== undefined) { + hash.update(input.requestData); + } + + return hash.digest('hex'); + } + + public static verifyChallengeResponse(input: { + requiredLeadingZerosInResultingHash: number; + challenge: string; + responseNonce: string; + requestData: string; + }): void { + const computedHash = this.computeHash(input); + + const hasSufficientLeadingZeros = computedHash.startsWith( + '0'.repeat(input.requiredLeadingZerosInResultingHash), + ); + + if (!hasSufficientLeadingZeros) { + throw new DwnServerError( + DwnServerErrorCode.ProofOfWorkInsufficientLeadingZeros, + `Insufficient leading zeros for computed hash ${computedHash}, needs ${input.requiredLeadingZerosInResultingHash} zeros.`, + ); + } + } + + public static findQualifiedNonce(input: { + requiredLeadingZerosInResultingHash: number; + challenge: string; + requestData?: string; + }): string { + const startTime = Date.now(); + + const { requiredLeadingZerosInResultingHash, challenge, requestData } = + input; + + const requiredHashPrefix = '0'.repeat(requiredLeadingZerosInResultingHash); + + let iterations = 1; + let randomNonce; + let hasSufficientLeadingZeros = false; + do { + randomNonce = this.generateNonce(); + const computedHash = this.computeHash({ + challenge, + responseNonce: randomNonce, + requestData, + }); + + hasSufficientLeadingZeros = computedHash.startsWith(requiredHashPrefix); + + iterations++; + + // Log every 1M iterations. + if (iterations % 1_000_000 === 0) { + console.log( + `iterations: ${iterations}, time lapsed: ${ + Date.now() - startTime + } ms`, + ); + } + } while (!hasSufficientLeadingZeros); + + // Log final/successful attempt. + console.log( + `iterations: ${iterations}, time lapsed: ${Date.now() - startTime} ms`, + ); + + return randomNonce; + } + + public static generateNonce(size: number = 32): string { + const nonceChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + let nonce = ''; + while (nonce.length < size) { + nonce += nonceChars.charAt(Math.floor(Math.random() * nonceChars.length)); + } + return nonce; + } +} diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index a630181..6d4329f 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -30,14 +30,13 @@ import { JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; import type { RegisteredTenantGate } from '../src/registered-tenant-gate.js'; +import { ProofOfWork } from '../src/registration/proof-of-work.js'; import { getTestDwn } from './test-dwn.js'; import type { Profile } from './utils.js'; import { createRecordsWriteMessage, getFileAsReadStream, streamHttpRequest, - checkNonce, - generateNonce, } from './utils.js'; if (!globalThis.crypto) { @@ -108,10 +107,10 @@ describe('http api', function () { expect(body.complexity).to.equal(5); // solve the challenge - let response = ''; - while (!checkNonce(body.challenge, response, body.complexity)) { - response = generateNonce(5); - } + const qualifiedNonce = ProofOfWork.findQualifiedNonce({ + challenge: body.challenge, + requiredLeadingZerosInResultingHash: body.complexity, + }); const p = await DidKeyResolver.generate(); const submitResponse = await fetch(proofOfWorkUrl, { @@ -119,7 +118,7 @@ describe('http api', function () { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge: body.challenge, - response: response, + response: qualifiedNonce, did: p.did, }), }); @@ -164,10 +163,10 @@ describe('http api', function () { clock.runToLast(); // solve the challenge - let response = ''; - while (!checkNonce(body.challenge, response, body.complexity)) { - response = generateNonce(5); - } + const qualifiedNonce = ProofOfWork.findQualifiedNonce({ + challenge: body.challenge, + requiredLeadingZerosInResultingHash: body.complexity, + }); const p = await DidKeyResolver.generate(); const submitResponse = await fetch(proofOfWorkUrl, { @@ -175,7 +174,7 @@ describe('http api', function () { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge: body.challenge, - response: response, + response: qualifiedNonce, did: p.did, }), }); @@ -200,41 +199,17 @@ describe('http api', function () { expect(body.challenge.length).to.equal(16); // solve the challenge - let response = ''; - let iterations = 0; - const start = Date.now(); - while (!checkNonce(body.challenge, response, body.complexity)) { - response = generateNonce(5); - iterations++; - if (iterations % 10000000 == 0) { - console.log( - 'complexity:', - body.complexity, - 'iteration count:', - iterations, - 'duration:', - Date.now() - start, - 'ms', - ); - } - } - - console.log( - 'complexity:', - body.complexity, - 'iteration count:', - iterations, - 'duration:', - Date.now() - start, - 'ms', - ); + const qualifiedNonce = ProofOfWork.findQualifiedNonce({ + challenge: body.challenge, + requiredLeadingZerosInResultingHash: body.complexity, + }); const submitResponse = await fetch(proofOfWorkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge: body.challenge, - response: response, + response: qualifiedNonce, did: p.did, }), }); @@ -251,21 +226,13 @@ describe('http api', function () { }; expect(body.challenge.length).to.equal(16); - // generate a nonce - let response = generateNonce(5); - // make sure the nonce is INVALID - // loop continues until checkNonce returns false, which is will probably do on the first iteration - while (checkNonce(body.challenge, response, body.complexity)) { - response = generateNonce(5); - } - const p = await DidKeyResolver.generate(); const submitResponse = await fetch(proofOfWorkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge: body.challenge, - response: response, + response: 'insufficient-nonce', did: p.did, }), }); @@ -274,21 +241,21 @@ describe('http api', function () { }); it('rejects a challenge it did not issue', async function () { - const challenge = generateNonce(10); + const unknownChallenge = 'unknown-challenge'; // solve the challenge - let response = ''; - while (!checkNonce(challenge, response, 2)) { - response = generateNonce(5); - } + const qualifiedNonce = ProofOfWork.findQualifiedNonce({ + challenge: unknownChallenge, + requiredLeadingZerosInResultingHash: 1, + }); const p = await DidKeyResolver.generate(); const submitResponse = await fetch(proofOfWorkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - challenge: challenge, - response: response, + challenge: unknownChallenge, + response: qualifiedNonce, did: p.did, }), }); diff --git a/tests/utils.ts b/tests/utils.ts index 6b3d9dd..5cb1d9b 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,7 +1,6 @@ import type { PrivateJwk, PublicJwk, Signer } from '@tbd54566975/dwn-sdk-js'; import { Cid, DataStream, RecordsWrite } from '@tbd54566975/dwn-sdk-js'; -import { createHash } from 'crypto'; import type { ReadStream } from 'node:fs'; import fs from 'node:fs'; import http from 'node:http'; @@ -166,28 +165,3 @@ export async function sendWsMessage( }; }); } - -const nonceChars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - -export function generateNonce(size: number): string { - let challenge = ''; - while (challenge.length < size) { - challenge += nonceChars.charAt( - Math.floor(Math.random() * nonceChars.length), - ); - } - return challenge; -} - -export function checkNonce( - challenge: string, - nonce: string, - complexity: number, -): boolean { - const hash = createHash('sha256'); - hash.update(challenge); - hash.update(nonce); - - return hash.digest('hex').startsWith('0'.repeat(complexity)); -} From ba3376ab537bdcb4e9fa49f056ee200c85bf184d Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 4 Jan 2024 10:14:24 -0800 Subject: [PATCH 06/27] Reused nonce verification code in ProofOfWork library --- src/registered-tenant-gate.ts | 35 ++++++++++++++++++++----------- src/registration/proof-of-work.ts | 2 +- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/registered-tenant-gate.ts b/src/registered-tenant-gate.ts index 87fa2d0..7aa6021 100644 --- a/src/registered-tenant-gate.ts +++ b/src/registered-tenant-gate.ts @@ -6,6 +6,10 @@ import type { Express } from 'express'; import type { Dialect } from 'kysely'; import { Kysely } from 'kysely'; +import type { DwnServerError } from './dwn-error.js'; +import { DwnServerErrorCode } from './dwn-error.js'; +import { ProofOfWork } from './registration/proof-of-work.js'; + const recentChallenges: { [challenge: string]: number } = {}; const CHALLENGE_TIMEOUT = 5 * 60 * 1000; // challenges are valid this long after issuance const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this time frame @@ -161,19 +165,26 @@ export class RegisteredTenantGate implements TenantGate { return; } - const hash = createHash('sha256'); - hash.update(body.challenge); - hash.update(body.response); - - const complexity = await this.getComplexity(); - const digest = hash.digest('hex'); - if (!digest.startsWith('0'.repeat(complexity))) { - res.status(401).json({ - success: false, - reason: 'insufficiently complex', - requiredComplexity: complexity, + try { + ProofOfWork.verifyChallengeResponse({ + challenge: body.challenge, + responseNonce: body.response, + requiredLeadingZerosInResultingHash: await this.getComplexity(), }); - return; + } catch (error) { + const dwnServerError = error as DwnServerError; + + if ( + dwnServerError.code === + DwnServerErrorCode.ProofOfWorkInsufficientLeadingZeros + ) { + res.status(401).json({ + success: false, + reason: dwnServerError.message, + }); + + return; + } } try { diff --git a/src/registration/proof-of-work.ts b/src/registration/proof-of-work.ts index 984cbbc..a69a5b8 100644 --- a/src/registration/proof-of-work.ts +++ b/src/registration/proof-of-work.ts @@ -23,7 +23,7 @@ export class ProofOfWork { requiredLeadingZerosInResultingHash: number; challenge: string; responseNonce: string; - requestData: string; + requestData?: string; }): void { const computedHash = this.computeHash(input); From 5a4984f6252f52a864b183b0fa422dd29726ccbf Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 4 Jan 2024 12:23:30 -0800 Subject: [PATCH 07/27] Removed coupling between Express and tenant gate implementation + removed Prettier --- .eslintrc.cjs | 4 +- .prettierignore | 1 - .prettierrc.json | 12 - CONTRIBUTING.md | 7 +- README.md | 1 - package-lock.json | 710 ---------------------------------- package.json | 13 +- src/dwn-error.ts | 2 + src/dwn-server.ts | 2 +- src/http-api.ts | 131 ++++--- src/registered-tenant-gate.ts | 148 ++----- tests/http-api.spec.ts | 2 +- 12 files changed, 121 insertions(+), 912 deletions(-) delete mode 100644 .prettierignore delete mode 100644 .prettierrc.json diff --git a/.eslintrc.cjs b/.eslintrc.cjs index caf385d..d5029b3 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -6,7 +6,6 @@ module.exports = { }, plugins: [ '@typescript-eslint', - 'prettier', 'todo-plz', // for enforcing TODO formatting to require "github.com/TBD54566975/dwn-server/issues/" ], env: { @@ -14,7 +13,7 @@ module.exports = { browser: true, }, rules: { - 'prettier/prettier': 'error', + 'max-len': ['error', { code: 150, ignoreStrings: true }], curly: ['error', 'all'], 'no-console': 'off', '@typescript-eslint/explicit-function-return-type': ['error'], @@ -37,5 +36,4 @@ module.exports = { { commentPattern: '.*github.com/TBD54566975/dwn-server/issues/.*' }, ], }, - extends: ['prettier'], }; diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 340c984..0000000 --- a/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -**/*.yaml \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 18ab2db..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "singleQuote": true, - "plugins": ["@trivago/prettier-plugin-sort-imports"], - "importOrder": [ - "^@tbd54566975/(.*)$", - "", - "^./lib/(.*)$", - "^[./]" - ], - "importOrderCaseInsensitive": true, - "importOrderSeparation": true -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c60619..5e62fe3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,9 +70,9 @@ We take our open-source community seriously. Please adhere to our [Code of Condu ### Code Style -- Our preferred code style has been codified into `eslint` and `prettier`. - - Feel free to take a look onto [eslint config](https://github.com/TBD54566975/dwn-server/blob/main/.eslintrc.cjs) and [prettier config](https://github.com/TBD54566975/dwn-server/blob/main/.prettierrc.json). -- Running `npm run lint:fix` and `npm run prettier:fix`will auto-format as much they can. Everything they weren't able to will be printed out as errors or warnings. +- Our preferred code style has been codified into `eslint`. + - Feel free to take a look onto [eslint config](https://github.com/TBD54566975/dwn-server/blob/main/.eslintrc.cjs). +- Running `npm run lint:fix` will auto-format as much they can. Everything they weren't able to will be printed out as errors or warnings. - We have a pre-commit hook which would run both commands with attempt to autofix problems - It runs by [husky](https://github.com/TBD54566975/dwn-server/blob/main/.husky/pre-commit) and executes [lint-staged command](https://github.com/TBD54566975/dwn-server/blob/main/package.json#L89) - Make sure that no errors/warnings are introduced in your PR @@ -91,7 +91,6 @@ We take our open-source community seriously. Please adhere to our [Code of Condu | `npm run clean` | deletes compiled JS | | `npm run lint` | runs linter | | `npm run lint:fix` | runs linter and fixes auto-fixable problems | -| `npm run prettier:fix` | runs prettier and fixes auto-fixable problems | | `npm run test` | runs tests | | `npm run server` | starts server | | `npm run prepare` | prepares husky for pre-commit hooks (auto-runs with `npm install`) | diff --git a/README.md b/README.md index 67905d5..0ad2d77 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,6 @@ cloudflared tunnel --url http://localhost:3000 | `npm run clean` | deletes compiled JS | | `npm run lint` | runs linter | | `npm run lint:fix` | runs linter and fixes auto-fixable problems | -| `npm run prettier:fix` | runs prettier and fixes auto-fixable problems | | `npm run test` | runs tests | | `npm run server` | starts server | | `npm run prepare` | prepares husky for pre-commit hooks (auto-runs with `npm install`) | diff --git a/package-lock.json b/package-lock.json index 7a8866a..9812720 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,6 @@ "dwn-server": "dist/esm/src/main.js" }, "devDependencies": { - "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/bytes": "3.1.1", "@types/chai": "4.3.4", "@types/express": "4.17.17", @@ -50,10 +49,8 @@ "crypto-browserify": "^3.12.0", "esbuild": "0.16.17", "eslint": "8.33.0", - "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "2.26.0", "eslint-plugin-mocha": "10.1.0", - "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-todo-plz": "^1.3.0", "http-proxy": "^1.18.1", "husky": "^8.0.0", @@ -63,7 +60,6 @@ "karma-mocha": "^2.0.1", "lint-staged": "^14.0.1", "mocha": "^10.2.0", - "prettier": "3.0.3", "puppeteer": "^21.4.0", "sinon": "16.1.0", "stream-browserify": "^3.0.0", @@ -98,117 +94,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/generator": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.7.tgz", - "integrity": "sha512-oLcVCTeIFadUoArDTwpluncplrYBmTCCZZgXCbgNGvOBBiSDDK3eWO4b/+eOTli5tKv1lg+a5/NAXg+nTcei1w==", - "dev": true, - "dependencies": { - "@babel/types": "^7.17.0", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-validator-identifier": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", @@ -232,109 +117,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", - "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "debug": "^4.1.0", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.23.0", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", - "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -547,20 +329,6 @@ "node": ">=8" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", @@ -570,15 +338,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -716,26 +475,6 @@ "node": ">= 8" } }, - "node_modules/@pkgr/utils": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", - "integrity": "sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "fast-glob": "^3.3.0", - "is-glob": "^4.0.3", - "open": "^9.1.0", - "picocolors": "^1.0.0", - "tslib": "^2.6.0" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, "node_modules/@puppeteer/browsers": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.8.0.tgz", @@ -1001,29 +740,6 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "dev": true }, - "node_modules/@trivago/prettier-plugin-sort-imports": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.1.tgz", - "integrity": "sha512-iuy2MPVURGdxILTchHr15VAioItuYBejKfcTmQFlxIuqA7jeaT6ngr5aUIG6S6U096d6a6lJCgaOwlRrPLlOPg==", - "dev": true, - "dependencies": { - "@babel/generator": "7.17.7", - "@babel/parser": "^7.20.5", - "@babel/traverse": "7.23.2", - "@babel/types": "7.17.0", - "javascript-natural-sort": "0.7.1", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "@vue/compiler-sfc": "3.x", - "prettier": "2.x - 3.x" - }, - "peerDependenciesMeta": { - "@vue/compiler-sfc": { - "optional": true - } - } - }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -1776,15 +1492,6 @@ "prebuild-install": "^7.1.1" } }, - "node_modules/big-integer": { - "version": "1.6.51", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", - "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1887,18 +1594,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/bplist-parser": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", - "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", - "dev": true, - "dependencies": { - "big-integer": "^1.6.44" - }, - "engines": { - "node": ">= 5.10.0" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2091,21 +1786,6 @@ "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", "dev": true }, - "node_modules/bundle-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", - "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", - "dev": true, - "dependencies": { - "run-applescript": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2879,40 +2559,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/default-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", - "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", - "dev": true, - "dependencies": { - "bundle-name": "^3.0.0", - "default-browser-id": "^3.0.0", - "execa": "^7.1.1", - "titleize": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", - "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", - "dev": true, - "dependencies": { - "bplist-parser": "^0.2.0", - "untildify": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-data-property": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", @@ -2926,18 +2572,6 @@ "node": ">= 0.4" } }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -3501,18 +3135,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", - "dev": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -3629,35 +3251,6 @@ "eslint": ">=7.0.0" } }, - "node_modules/eslint-plugin-prettier": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz", - "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==", - "dev": true, - "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.8.5" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, "node_modules/eslint-plugin-todo-plz": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-plugin-todo-plz/-/eslint-plugin-todo-plz-1.3.0.tgz", @@ -4195,12 +3788,6 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true - }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -4698,15 +4285,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/globalthis": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", @@ -5360,21 +4938,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5408,24 +4971,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -5594,33 +5139,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-wsl/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -5802,12 +5320,6 @@ "npm": ">=7.0.0" } }, - "node_modules/javascript-natural-sort": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", - "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", - "dev": true - }, "node_modules/js-sdsl": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.2.tgz", @@ -5841,18 +5353,6 @@ "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz", "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==" }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7311,24 +6811,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", - "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", - "dev": true, - "dependencies": { - "default-browser": "^4.0.0", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -7676,12 +7158,6 @@ "split2": "^4.1.0" } }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -7775,33 +7251,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", - "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -8354,110 +7803,6 @@ "inherits": "^2.0.1" } }, - "node_modules/run-applescript": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", - "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", - "dev": true, - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/run-applescript/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/run-applescript/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/run-applescript/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/run-applescript/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8965,15 +8310,6 @@ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "dev": true }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sparse-array": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/sparse-array/-/sparse-array-1.3.2.tgz", @@ -9279,22 +8615,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/synckit": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz", - "integrity": "sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==", - "dev": true, - "dependencies": { - "@pkgr/utils": "^2.3.1", - "tslib": "^2.5.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, "node_modules/tar-fs": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", @@ -9401,18 +8721,6 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, - "node_modules/titleize": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", - "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -9425,15 +8733,6 @@ "node": ">=8.17.0" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9760,15 +9059,6 @@ "node": ">= 0.8" } }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 0736185..95deace 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "ws": "8.12.0" }, "devDependencies": { - "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/bytes": "3.1.1", "@types/chai": "4.3.4", "@types/express": "4.17.17", @@ -65,10 +64,8 @@ "crypto-browserify": "^3.12.0", "esbuild": "0.16.17", "eslint": "8.33.0", - "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "2.26.0", "eslint-plugin-mocha": "10.1.0", - "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-todo-plz": "^1.3.0", "http-proxy": "^1.18.1", "husky": "^8.0.0", @@ -78,7 +75,6 @@ "karma-mocha": "^2.0.1", "lint-staged": "^14.0.1", "mocha": "^10.2.0", - "prettier": "3.0.3", "puppeteer": "^21.4.0", "sinon": "16.1.0", "stream-browserify": "^3.0.0", @@ -92,7 +88,6 @@ "clean": "rimraf dist && rimraf generated/*", "lint": "eslint . --ext .ts --max-warnings 0", "lint:fix": "eslint . --ext .ts --fix", - "prettier:fix": "prettier . --write --ignore-unknown", "test": "npm run build:esm && cp -R tests/fixtures dist/esm/tests && c8 mocha", "server": "npm run build:esm && node dist/esm/src/main.js", "prepare": "husky install" @@ -104,9 +99,7 @@ }, "lint-staged": { "*.{js,ts}": [ - "eslint --fix", - "prettier --ignore-unknown --write" - ], - "*": "prettier --ignore-unknown --write" + "eslint --fix" + ] } -} +} \ No newline at end of file diff --git a/src/dwn-error.ts b/src/dwn-error.ts index 6a55a2e..f9dff4a 100644 --- a/src/dwn-error.ts +++ b/src/dwn-error.ts @@ -17,4 +17,6 @@ export class DwnServerError extends Error { */ export enum DwnServerErrorCode { ProofOfWorkInsufficientLeadingZeros = 'ProofOfWorkInsufficientLeadingZeros', + ProofOfWorkInvalidOrExpiredChallenge = 'ProofOfWorkInvalidOrExpiredChallenge', + TenantRegistrationOutdatedTermsOfService = 'TenantRegistrationOutdatedTermsOfService', } diff --git a/src/dwn-server.ts b/src/dwn-server.ts index 59c87c3..7c45105 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -69,7 +69,7 @@ export class DwnServer { this.dwn = await Dwn.create(getDWNConfig(this.config, tenantGate)); } - this.#httpApi = new HttpApi(this.dwn, tenantGate); + this.#httpApi = new HttpApi(this.config, this.dwn, tenantGate); await this.#httpApi.start(this.config.port, () => { log.info(`HttpServer listening on port ${this.config.port}`); }); diff --git a/src/http-api.ts b/src/http-api.ts index 6928a9d..dfc7903 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -1,8 +1,4 @@ -import { - type Dwn, - RecordsRead, - type RecordsReadReply, -} from '@tbd54566975/dwn-sdk-js'; +import { type Dwn, RecordsRead, type RecordsReadReply } from '@tbd54566975/dwn-sdk-js'; import cors from 'cors'; import type { Express, Request, Response } from 'express'; @@ -16,27 +12,26 @@ import { v4 as uuidv4 } from 'uuid'; 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 { createJsonRpcErrorResponse, JsonRpcErrorCodes } from './lib/json-rpc.js'; +import type { Config } from './config.js'; import { config } from './config.js'; +import { type DwnServerError } from './dwn-error.js'; import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter, responseHistogram } from './metrics.js'; import type { RegisteredTenantGate } from './registered-tenant-gate.js'; -const packageJson = process.env.npm_package_json - ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) - : {}; +const packageJson = process.env.npm_package_json ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) : {}; export class HttpApi { + #config: Config; #api: Express; #server: http.Server; tenantGate: RegisteredTenantGate; dwn: Dwn; - constructor(dwn: Dwn, tenantGate: RegisteredTenantGate) { + constructor(config: Config, dwn: Dwn, tenantGate: RegisteredTenantGate) { + this.#config = config; this.#api = express(); this.#server = http.createServer(this.#api); this.dwn = dwn; @@ -61,10 +56,7 @@ export class HttpApi { this.#api.use( responseTime((req: Request, res: Response, time) => { const url = req.url === '/' ? '/jsonrpc' : req.url; - const route = (req.method + url) - .toLowerCase() - .replace(/[:.]/g, '') - .replace(/\//g, '_'); + const route = (req.method + url).toLowerCase().replace(/[:.]/g, '').replace(/\//g, '_'); const statusCode = res.statusCode.toString(); responseHistogram.labels(route, statusCode).observe(time); @@ -94,10 +86,7 @@ export class HttpApi { const record = await RecordsRead.create({ filter: { recordId: req.params.id }, }); - const reply = (await this.dwn.processMessage( - req.params.did, - record.toJSON(), - )) as RecordsReadReply; + const reply = (await this.dwn.processMessage(req.params.did, record.toJSON())) as RecordsReadReply; if (reply.status.code === 200) { if (reply?.record?.data) { @@ -121,20 +110,14 @@ export class HttpApi { this.#api.get('/', (_req, res) => { // return a plain text string res.setHeader('content-type', 'text/plain'); - return res.send( - 'please use a web5 client, for example: https://github.com/TBD54566975/web5-js ', - ); + return res.send('please use a web5 client, for example: https://github.com/TBD54566975/web5-js '); }); this.#api.post('/', async (req: Request, res) => { const dwnRequest = req.headers['dwn-request'] as any; if (!dwnRequest) { - const reply = createJsonRpcErrorResponse( - uuidv4(), - JsonRpcErrorCodes.BadRequest, - 'request payload required.', - ); + const reply = createJsonRpcErrorResponse(uuidv4(), JsonRpcErrorCodes.BadRequest, 'request payload required.'); return res.status(400).json(reply); } @@ -143,11 +126,7 @@ export class HttpApi { try { dwnRpcRequest = JSON.parse(dwnRequest); } catch (e) { - const reply = createJsonRpcErrorResponse( - uuidv4(), - JsonRpcErrorCodes.BadRequest, - e.message, - ); + const reply = createJsonRpcErrorResponse(uuidv4(), JsonRpcErrorCodes.BadRequest, e.message); return res.status(400).json(reply); } @@ -155,21 +134,14 @@ export class HttpApi { // Check whether data was provided in the request body const contentLength = req.headers['content-length']; const transferEncoding = req.headers['transfer-encoding']; - const requestDataStream = - parseInt(contentLength) > 0 || transferEncoding !== undefined - ? req - : undefined; + const requestDataStream = parseInt(contentLength) > 0 || transferEncoding !== undefined ? req : undefined; const requestContext: RequestContext = { - dwn: this.dwn, - transport: 'http', - dataStream: requestDataStream, + dwn : this.dwn, + transport : 'http', + dataStream : requestDataStream, }; - const { jsonRpcResponse, dataStream: responseDataStream } = - await jsonRpcApi.handle( - dwnRpcRequest, - requestContext as RequestContext, - ); + const { jsonRpcResponse, dataStream: responseDataStream } = await jsonRpcApi.handle(dwnRpcRequest, requestContext as RequestContext); // If the handler catches a thrown exception and returns a JSON RPC InternalError, return the equivalent // HTTP 500 Internal Server Error with the response. @@ -179,8 +151,8 @@ export class HttpApi { } requestCounter.inc({ - method: dwnRpcRequest.method, - status: jsonRpcResponse?.result?.reply?.status?.code || 0, + method : dwnRpcRequest.method, + status : jsonRpcResponse?.result?.reply?.status?.code || 0, }); if (responseDataStream) { res.setHeader('content-type', 'application/octet-stream'); @@ -192,9 +164,7 @@ export class HttpApi { } }); - if (this.tenantGate) { - this.tenantGate.setupRoutes(this.#api); - } + this.#setupRegistrationRoutes(); this.#api.get('/info.json', (req, res) => { res.setHeader('content-type', 'application/json'); @@ -207,11 +177,11 @@ export class HttpApi { } res.json({ - server: process.env.npm_package_name, - maxFileSize: config.maxRecordDataSize, - registrationRequirements: registrationRequirements, - version: packageJson.version, - sdkVersion: packageJson.dependencies['@tbd54566975/dwn-sdk-js'], + server : process.env.npm_package_name, + maxFileSize : config.maxRecordDataSize, + registrationRequirements : registrationRequirements, + version : packageJson.version, + sdkVersion : packageJson.dependencies['@tbd54566975/dwn-sdk-js'], }); }); } @@ -220,6 +190,55 @@ export class HttpApi { this.#server.listen(port, callback); } + #setupRegistrationRoutes(): void { + if (this.#config.registrationProofOfWorkEnabled) { + this.#api.get('/register/proof-of-work', async (_req: Request, res: Response) => { + const proofOfWorkChallenge = await this.tenantGate.getProofOfWorkChallenge(); + res.json(proofOfWorkChallenge); + }); + + this.#api.post('/register/proof-of-work', async (req: Request, res: Response) => { + try { + await this.tenantGate.handleProofOfWorkChallengePost(req.body); + res.json({ success: true }); + } catch (error) { + const dwnServerError = error as DwnServerError; + + if (dwnServerError.code !== undefined) { + res.status(401).json({ + success : false, + reason : dwnServerError.message, + }); + } else { + console.log('Error handling proof-of-work POST:', error); + res.status(500).json({ success: false }); + } + } + }); + } + if (this.#config.termsOfServiceFilePath !== undefined) { + this.#api.get('/register/terms-of-service', (_req: Request, res: Response) => res.send(this.tenantGate.termsOfService)); + this.#api.post('/register/terms-of-service', async (req: Request, res: Response) => { + try { + await this.tenantGate.handleTermsOfServicePost(req.body); + res.status(200).json({ success: true }); + } catch (error) { + const dwnServerError = error as DwnServerError; + + if (dwnServerError.code !== undefined) { + res.status(400).json({ + success : false, + reason : dwnServerError.message, + }); + } else { + console.log('Error handling terms-of-service POST:', error); + res.status(500).json({ success: false }); + } + } + }); + } + } + async start(port: number, callback?: () => void): Promise { if (this.tenantGate) { await this.tenantGate.initialize(); diff --git a/src/registered-tenant-gate.ts b/src/registered-tenant-gate.ts index 7aa6021..449e153 100644 --- a/src/registered-tenant-gate.ts +++ b/src/registered-tenant-gate.ts @@ -1,12 +1,10 @@ import type { TenantGate } from '@tbd54566975/dwn-sdk-js'; import { createHash, randomBytes } from 'crypto'; -import type { Request, Response } from 'express'; -import type { Express } from 'express'; import type { Dialect } from 'kysely'; import { Kysely } from 'kysely'; -import type { DwnServerError } from './dwn-error.js'; +import { DwnServerError } from './dwn-error.js'; import { DwnServerErrorCode } from './dwn-error.js'; import { ProofOfWork } from './registration/proof-of-work.js'; @@ -15,17 +13,22 @@ const CHALLENGE_TIMEOUT = 5 * 60 * 1000; // challenges are valid this long after const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this time frame const COMPLEXITY_MINIMUM = 5; +type ProofOfWorkChallengeModel = { + challenge: string; + complexity: number; +}; + export class RegisteredTenantGate implements TenantGate { #db: Kysely; #proofOfWorkRequired: boolean; - #termsOfService?: string; #termsOfServiceHash?: string; + #termsOfService?: string; + + get termsOfService(): string { + return this.#termsOfService; + } - constructor( - dialect: Dialect, - proofOfWorkRequired: boolean, - termsOfService?: string, - ) { + constructor(dialect: Dialect, proofOfWorkRequired: boolean, termsOfService?: string) { this.#db = new Kysely({ dialect: dialect }); this.#proofOfWorkRequired = proofOfWorkRequired; @@ -40,10 +43,7 @@ export class RegisteredTenantGate implements TenantGate { async initialize(): Promise { setInterval(() => { for (const challenge of Object.keys(recentChallenges)) { - if ( - recentChallenges[challenge] && - Date.now() - recentChallenges[challenge] > CHALLENGE_TIMEOUT - ) { + if (recentChallenges[challenge] && Date.now() - recentChallenges[challenge] > CHALLENGE_TIMEOUT) { delete recentChallenges[challenge]; } } @@ -58,25 +58,6 @@ export class RegisteredTenantGate implements TenantGate { .execute(); } - setupRoutes(server: Express): void { - if (this.#proofOfWorkRequired) { - server.get('/register/proof-of-work', (req: Request, res: Response) => - this.getProofOfWorkChallenge(req, res), - ); - server.post('/register/proof-of-work', (req: Request, res: Response) => - this.verifyProofOfWorkChallenge(req, res), - ); - } - if (this.#termsOfService) { - server.get('/register/terms-of-service', (req: Request, res: Response) => - res.send(this.#termsOfService), - ); - server.post('/register/terms-of-service', (req: Request, res: Response) => - this.handleTermsOfServicePost(req, res), - ); - } - } - async isTenant(tenant: string): Promise { if (!this.#proofOfWorkRequired && !this.#termsOfService) { return true; @@ -103,14 +84,8 @@ export class RegisteredTenantGate implements TenantGate { return false; } - if ( - this.#termsOfService && - row.termsOfServiceHash != this.#termsOfServiceHash - ) { - console.log( - 'rejecting tenant that has not accepted the current terms of service', - { row, tenant, expected: this.#termsOfServiceHash }, - ); + if (this.#termsOfService && row.termsOfServiceHash != this.#termsOfServiceHash) { + console.log('rejecting tenant that has not accepted the current terms of service', { row, tenant, expected: this.#termsOfServiceHash }); return false; } @@ -132,69 +107,29 @@ export class RegisteredTenantGate implements TenantGate { .executeTakeFirst(); } - private async getProofOfWorkChallenge( - _req: Request, - res: Response, - ): Promise { + async getProofOfWorkChallenge(): Promise { const challenge = randomBytes(10).toString('base64'); recentChallenges[challenge] = Date.now(); - res.json({ + return { challenge: challenge, complexity: await this.getComplexity(), - }); + }; } - private async verifyProofOfWorkChallenge( - req: Request, - res: Response, - ): Promise { - const body: { - did: string; - challenge: string; - response: string; - } = req.body; - + async handleProofOfWorkChallengePost(body: { did: string; challenge: string; response: string }): Promise { const challengeIssued = recentChallenges[body.challenge]; - if ( - challengeIssued == undefined || - Date.now() - challengeIssued > CHALLENGE_TIMEOUT - ) { - res - .status(401) - .json({ success: false, reason: 'challenge invalid or expired' }); - return; - } - try { - ProofOfWork.verifyChallengeResponse({ - challenge: body.challenge, - responseNonce: body.response, - requiredLeadingZerosInResultingHash: await this.getComplexity(), - }); - } catch (error) { - const dwnServerError = error as DwnServerError; - - if ( - dwnServerError.code === - DwnServerErrorCode.ProofOfWorkInsufficientLeadingZeros - ) { - res.status(401).json({ - success: false, - reason: dwnServerError.message, - }); - - return; - } + if (challengeIssued == undefined || Date.now() - challengeIssued > CHALLENGE_TIMEOUT) { + throw new DwnServerError(DwnServerErrorCode.ProofOfWorkInvalidOrExpiredChallenge, `Invalid or expired challenge: ${body.challenge}.`); } - try { - await this.authorizeTenantProofOfWork(body.did); - } catch (e) { - console.log('error inserting did', e); - res.status(500).json({ success: false }); - return; - } - res.json({ success: true }); + ProofOfWork.verifyChallengeResponse({ + challenge: body.challenge, + responseNonce: body.response, + requiredLeadingZerosInResultingHash: await this.getComplexity(), + }); + + await this.authorizeTenantProofOfWork(body.did); } private async getComplexity(): Promise { @@ -216,20 +151,13 @@ export class RegisteredTenantGate implements TenantGate { return complexity; } - private async handleTermsOfServicePost( - req: Request, - res: Response, - ): Promise { - const body: { - did: string; - termsOfServiceHash: string; - } = req.body; + async handleTermsOfServicePost(body: { + did: string; + termsOfServiceHash: string; + }): Promise { if (body.termsOfServiceHash != this.#termsOfServiceHash) { - res.status(400).json({ - success: false, - reason: 'incorrect terms of service hash', - }); + throw new DwnServerError(DwnServerErrorCode.TenantRegistrationOutdatedTermsOfService, `Outdated terms of service.`); } console.log('accepting terms of service', body); @@ -244,15 +172,11 @@ export class RegisteredTenantGate implements TenantGate { // to the `termsOfServiceHash` of the row that was attempted to be inserted (`excluded.termsOfServiceHash`). .onConflict((onConflictBuilder) => onConflictBuilder.column('did').doUpdateSet((expressionBuilder) => ({ - termsOfServiceHash: expressionBuilder.ref( - 'excluded.termsOfServiceHash', - ), + termsOfServiceHash: expressionBuilder.ref('excluded.termsOfServiceHash'), })), ) // Executes the query. If the query doesn’t affect any rows (ie. if the insert or update didn’t change anything), it throws an error. .executeTakeFirstOrThrow(); - - res.status(200).json({ success: true }); } async authorizeTenantTermsOfService(tenant: string): Promise { @@ -264,9 +188,7 @@ export class RegisteredTenantGate implements TenantGate { }) .onConflict((onConflictBuilder) => onConflictBuilder.column('did').doUpdateSet((expressionBuilder) => ({ - termsOfServiceHash: expressionBuilder.ref( - 'excluded.termsOfServiceHash', - ), + termsOfServiceHash: expressionBuilder.ref('excluded.termsOfServiceHash'), })), ) // Executes the query. No error is thrown if the query doesn’t affect any rows (ie. if the insert or update didn’t change anything). diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 6d4329f..7954abf 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -61,7 +61,7 @@ describe('http api', function () { dwn = testDwn.dwn; tenantGate = testDwn.tenantGate; - httpApi = new HttpApi(dwn, tenantGate); + httpApi = new HttpApi(config, dwn, tenantGate); await tenantGate.initialize(); profile = await DidKeyResolver.generate(); From bb3eb3a0f57942f259aaa42c9c124768a7cdd0a8 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 4 Jan 2024 23:01:31 -0800 Subject: [PATCH 08/27] Added ProofOfWorkManager to manage the PoW difficulty --- src/registered-tenant-gate.ts | 9 +- src/registration/proof-of-work-manager.ts | 134 ++++++++++++++++++ tests/http-api.spec.ts | 23 +-- .../proof-of-work-manager.spec.ts | 69 +++++++++ 4 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 src/registration/proof-of-work-manager.ts create mode 100644 tests/registration/proof-of-work-manager.spec.ts diff --git a/src/registered-tenant-gate.ts b/src/registered-tenant-gate.ts index 449e153..6828f0b 100644 --- a/src/registered-tenant-gate.ts +++ b/src/registered-tenant-gate.ts @@ -70,20 +70,13 @@ export class RegisteredTenantGate implements TenantGate { .where('did', '=', tenant) .execute(); - if (result.length == 0) { + if (result.length === 0) { console.log('rejecting tenant that is not in the database', { tenant }); return false; } const row = result[0]; - if (this.#proofOfWorkRequired && row.proofOfWorkTime == undefined) { - console.log('rejecting tenant that has not completed the proof of work', { - tenant, - }); - return false; - } - if (this.#termsOfService && row.termsOfServiceHash != this.#termsOfServiceHash) { console.log('rejecting tenant that has not accepted the current terms of service', { row, tenant, expected: this.#termsOfServiceHash }); return false; diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts new file mode 100644 index 0000000..c5461db --- /dev/null +++ b/src/registration/proof-of-work-manager.ts @@ -0,0 +1,134 @@ +export class ProofOfWorkManager { + private proofOfWorkOfLastMinute: Map = new Map(); // proofOfWorkId -> timestamp of proof-of-work + private currentMaximumHashValueAsBigInt: bigint; + private initialMaximumHashValueAsBigInt: bigint; + private desiredSolveCountPerMinute: number; + + static readonly #difficultyReevaluationFrequencyInMilliseconds = 10000; + + public get currentMaximumAllowedHashValue(): bigint { + return this.currentMaximumHashValueAsBigInt; + } + + public get currentSolveCountPerMinute(): number { + return this.proofOfWorkOfLastMinute.size; + } + + private constructor (desiredSolveCountPerMinute: number, initialMaximumHashValue: string) { + this.currentMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); + this.initialMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); + this.desiredSolveCountPerMinute = desiredSolveCountPerMinute; + } + + public static async create( + desiredSolveCountPerMinute: number, + initialMaximumHashValue: string, + autoStart: boolean = false + ): Promise { + const proofOfWorkManager = new ProofOfWorkManager(desiredSolveCountPerMinute, initialMaximumHashValue); + + if (autoStart) { + proofOfWorkManager.start(); + } + + return proofOfWorkManager; + } + + public start(): void { + this.periodicallyRefreshProofOfWorkDifficulty(); + } + + public async recordProofOfWork(proofOfWorkId: string): Promise { + this.proofOfWorkOfLastMinute.set(proofOfWorkId, Date.now()); + } + + private periodicallyRefreshProofOfWorkDifficulty (): void { + try { + this.refreshMaximumAllowedHashValue(); + } catch (error) { + console.error(`Encountered error while updating proof of work difficulty: ${error}`); + } finally { + setTimeout(async () => this.periodicallyRefreshProofOfWorkDifficulty(), ProofOfWorkManager.#difficultyReevaluationFrequencyInMilliseconds); + } + } + + public removeProofOfWorkOlderThanOneMinute (): void { + const oneMinuteAgo = Date.now() - 60 * 1000; + for (const proofOfWorkId of this.proofOfWorkOfLastMinute.keys()) { + if (this.proofOfWorkOfLastMinute.get(proofOfWorkId) < oneMinuteAgo) { + this.proofOfWorkOfLastMinute.delete(proofOfWorkId); + } + } + } + + /** + * Refreshes the difficulty by changing the max hash value. + * The higher the number, the easier. Scale 1 (hardest) to 2^256 (easiest), represented in HEX. + * + * If solve rate rate is higher than expected, the difficulty will increase rapidly. + * If solve rate is lower than expected, the difficulty will decrease gradually. + * The difficulty will never be lower than the initial difficulty. + */ + private hashValueIncrementPerEvaluation = BigInt(1); + async refreshMaximumAllowedHashValue (): Promise { + // Cleanup proof-of-work cache and update solve rate. + this.removeProofOfWorkOlderThanOneMinute(); + + const latestSolveCountPerMinute = this.proofOfWorkOfLastMinute.size; + + // NOTE: bigint arithmetic does NOT work with decimals, so we work with "full numbers" by multiplying by a scale factor. + const scaleFactor = 1_000_000; + const difficultyEvaluationsPerMinute = 60000 / ProofOfWorkManager.#difficultyReevaluationFrequencyInMilliseconds; // assumed to be >= 1; + + // NOTE: easier difficulty is represented by a larger max allowed hash value + // and harder difficulty is represented by a smaller max allowed hash value. + const currentSolveRateInFractionOfDesiredSolveRate = latestSolveCountPerMinute / this.desiredSolveCountPerMinute; + if (latestSolveCountPerMinute > this.desiredSolveCountPerMinute) { + this.hashValueIncrementPerEvaluation = undefined; + + // if solve rate is higher than desired, make difficulty harder by making the max allowed hash value smaller + + // set higher to make difficulty increase faster. + // This should also be relative to how often the difficulty is reevaluated if the reevaluation frequency is adjustable. + const increaseMultiplier = 1; + + const newMaximumHashValueAsBigIntPriorToMultiplierAdjustment + = (this.currentMaximumHashValueAsBigInt * BigInt(scaleFactor)) / + (BigInt(Math.floor(currentSolveRateInFractionOfDesiredSolveRate * increaseMultiplier * scaleFactor))); + + // set higher to make difficulty increase faster. + // This should also be relative to how often the difficulty is reevaluated if the reevaluation frequency is adjustable. + const hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment + = (this.currentMaximumHashValueAsBigInt - newMaximumHashValueAsBigIntPriorToMultiplierAdjustment) * + (BigInt(Math.floor(increaseMultiplier * scaleFactor)) / BigInt(scaleFactor)); + + const hashValueDecreaseAmount + = hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment / BigInt(difficultyEvaluationsPerMinute); + + let newMaximumHashValueAsBigInt = this.currentMaximumHashValueAsBigInt - hashValueDecreaseAmount; + + if (newMaximumHashValueAsBigInt === BigInt(0)) { + // if newMaximumHashValueAsBigInt is 0, we use 1 instead because 0 cannot multiply another number + newMaximumHashValueAsBigInt = BigInt(1); + } + + this.currentMaximumHashValueAsBigInt = newMaximumHashValueAsBigInt; + } else { + // if solve rate is lower than desired, make difficulty easier by making the max allowed hash value larger + + if (this.currentMaximumHashValueAsBigInt === this.initialMaximumHashValueAsBigInt) { + // if current difficulty is already at initial difficulty, don't make it any easier + return; + } + + if (this.hashValueIncrementPerEvaluation === undefined) { + const backToInitialDifficultyInMinutes = 10; + const differenceBetweenInitialAndCurrentDifficulty = this.initialMaximumHashValueAsBigInt - this.currentMaximumHashValueAsBigInt; + this.hashValueIncrementPerEvaluation + = differenceBetweenInitialAndCurrentDifficulty / BigInt(backToInitialDifficultyInMinutes * difficultyEvaluationsPerMinute); + } + + this.currentMaximumHashValueAsBigInt += this.hashValueIncrementPerEvaluation; + } + } +} diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 7954abf..8b6d7fc 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -286,28 +286,7 @@ describe('http api', function () { expect(response.body.result.reply.status.code).to.equal(401); }); - it('rejects tenants that have accepted the terms of use but not completed proof-of-work', async function () { - const unauthorized = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantTermsOfService(unauthorized.did); - const recordsQuery = await RecordsQuery.create({ - filter: { schema: 'woosa' }, - signer: unauthorized.signer, - }); - - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: unauthorized.did, - }); - - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); - - expect(response.statusCode).to.equal(200); - expect(response.body.id).to.equal(requestId); - expect(response.body.result.reply.status.code).to.equal(401); + xit('rejects registration that have accepted the terms of use but not completed proof-of-work', async function () { }); }); diff --git a/tests/registration/proof-of-work-manager.spec.ts b/tests/registration/proof-of-work-manager.spec.ts new file mode 100644 index 0000000..554ffae --- /dev/null +++ b/tests/registration/proof-of-work-manager.spec.ts @@ -0,0 +1,69 @@ + + +import { expect } from 'chai'; +import { useFakeTimers } from 'sinon'; +import { v4 as uuidv4 } from 'uuid'; +import { ProofOfWorkManager } from '../..//src/registration/proof-of-work-manager.js'; + + +describe('ProofOfWorkManager', function () { + let clock; + + before(async function () { + clock = useFakeTimers({ shouldAdvanceTime: true }); + }); + + beforeEach(async function () { + }); + + afterEach(async function () { + }); + + after(function () { + clock.restore(); + }); + + describe('complexity', function () { + + it('should become more complex as more successful proof-of-work is submitted', async function () { + const desiredSolveRatePerMinute = 10; + const initialMaximumHashValue = 'FFFFFFFF'; + const proofOfWorkManager = await ProofOfWorkManager.create(desiredSolveRatePerMinute, initialMaximumHashValue); + + // Load up desiredSolveRatePerMinute number of proof-of-work entries, so all future new entries will increase the complexity. + for (let i = 0; i < desiredSolveRatePerMinute; i++) { + await proofOfWorkManager.recordProofOfWork(uuidv4()); + } + + let lastMaximumAllowedHashValue = BigInt('0x' + initialMaximumHashValue); + for (let i = 0; i < 100; i++) { + // Simulating 1 proof-of-work per second which is ~60/min for 100 seconds. + clock.tick(1000); + await proofOfWorkManager.recordProofOfWork(uuidv4()); + await proofOfWorkManager.refreshMaximumAllowedHashValue(); + + // The maximum allowed hash value should be decreasing as more proof-of-work is submitted. + expect(proofOfWorkManager.currentMaximumAllowedHashValue < lastMaximumAllowedHashValue).to.be.true; + lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + } + + // Simulated 100 seconds has passed, so all proof-of-work entries should be removed. + clock.tick(100_000); + + for (let i = 0; i < 100; i++) { + // Simulating no proof-of-work load for 100 seconds. + + console.log(`iteration: ${i}`); + console.log(`maximumAllowedHashValue: ${proofOfWorkManager.currentMaximumAllowedHashValue}`); + console.log(`currentSolveCountPerMinute: ${proofOfWorkManager.currentSolveCountPerMinute}`); + + clock.tick(1000); + await proofOfWorkManager.refreshMaximumAllowedHashValue(); + + // The maximum allowed hash value should be increasing again. + expect(proofOfWorkManager.currentMaximumAllowedHashValue > lastMaximumAllowedHashValue).to.be.true; + lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + } + }); + }); +}); From 75eb96fc023940a688a59e358573145f9f69927d Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Fri, 5 Jan 2024 11:34:52 -0800 Subject: [PATCH 09/27] Swapping out leading-zero implementation with more granular implementation --- src/dwn-error.ts | 2 +- src/http-api.ts | 21 +++++++++ src/registered-tenant-gate.ts | 29 +++++++------ src/registration/proof-of-work-manager.ts | 19 +++++++- src/registration/proof-of-work-types.ts | 4 ++ src/registration/proof-of-work.ts | 28 ++++++------ src/registration/registration-manager.ts | 43 +++++++++++++++++++ tests/http-api.spec.ts | 32 ++++++-------- .../proof-of-work-manager.spec.ts | 5 --- 9 files changed, 129 insertions(+), 54 deletions(-) create mode 100644 src/registration/proof-of-work-types.ts create mode 100644 src/registration/registration-manager.ts diff --git a/src/dwn-error.ts b/src/dwn-error.ts index f9dff4a..9545717 100644 --- a/src/dwn-error.ts +++ b/src/dwn-error.ts @@ -16,7 +16,7 @@ export class DwnServerError extends Error { * DWN Server error codes. */ export enum DwnServerErrorCode { - ProofOfWorkInsufficientLeadingZeros = 'ProofOfWorkInsufficientLeadingZeros', + ProofOfWorkInsufficientSolutionNonce = 'ProofOfWorkInsufficientSolutionNonce', ProofOfWorkInvalidOrExpiredChallenge = 'ProofOfWorkInvalidOrExpiredChallenge', TenantRegistrationOutdatedTermsOfService = 'TenantRegistrationOutdatedTermsOfService', } diff --git a/src/http-api.ts b/src/http-api.ts index dfc7903..7a2c8c3 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -20,6 +20,7 @@ import { type DwnServerError } from './dwn-error.js'; import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter, responseHistogram } from './metrics.js'; import type { RegisteredTenantGate } from './registered-tenant-gate.js'; +import type { RegistrationManager } from './registration/registration-manager.js'; const packageJson = process.env.npm_package_json ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) : {}; @@ -28,6 +29,7 @@ export class HttpApi { #api: Express; #server: http.Server; tenantGate: RegisteredTenantGate; + registrationManager: RegistrationManager; dwn: Dwn; constructor(config: Config, dwn: Dwn, tenantGate: RegisteredTenantGate) { @@ -237,6 +239,25 @@ export class HttpApi { } }); } + + this.#api.post('/registration', async (req: Request, res: Response) => { + try { + await this.registrationManager.handleRegistrationRequest(req.body); + res.status(200).json({ success: true }); + } catch (error) { + const dwnServerError = error as DwnServerError; + + if (dwnServerError.code !== undefined) { + res.status(400).json({ + success : false, + reason : dwnServerError.message, + }); + } else { + console.log('Error handling registration request:', error); + res.status(500).json({ success: false }); + } + } + }); } async start(port: number, callback?: () => void): Promise { diff --git a/src/registered-tenant-gate.ts b/src/registered-tenant-gate.ts index 6828f0b..429b176 100644 --- a/src/registered-tenant-gate.ts +++ b/src/registered-tenant-gate.ts @@ -7,16 +7,12 @@ import { Kysely } from 'kysely'; import { DwnServerError } from './dwn-error.js'; import { DwnServerErrorCode } from './dwn-error.js'; import { ProofOfWork } from './registration/proof-of-work.js'; +import type { ProofOfWorkChallengeModel } from './registration/proof-of-work-types.js'; const recentChallenges: { [challenge: string]: number } = {}; const CHALLENGE_TIMEOUT = 5 * 60 * 1000; // challenges are valid this long after issuance const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this time frame -const COMPLEXITY_MINIMUM = 5; - -type ProofOfWorkChallengeModel = { - challenge: string; - complexity: number; -}; +const COMPLEXITY_MINIMUM = BigInt('0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'); export class RegisteredTenantGate implements TenantGate { #db: Kysely; @@ -105,10 +101,19 @@ export class RegisteredTenantGate implements TenantGate { recentChallenges[challenge] = Date.now(); return { challenge: challenge, - complexity: await this.getComplexity(), + complexity: this.bigIntToHexString(await this.getComplexity()), }; } + private bigIntToHexString (int: BigInt): string { + let hex = int.toString(16).toUpperCase(); + const stringLength = hex.length; + for (let pad = stringLength; pad < 64; pad++) { + hex = '0' + hex; + } + return hex; + } + async handleProofOfWorkChallengePost(body: { did: string; challenge: string; response: string }): Promise { const challengeIssued = recentChallenges[body.challenge]; @@ -119,24 +124,24 @@ export class RegisteredTenantGate implements TenantGate { ProofOfWork.verifyChallengeResponse({ challenge: body.challenge, responseNonce: body.response, - requiredLeadingZerosInResultingHash: await this.getComplexity(), + maximumAllowedHashValue: await this.getComplexity(), }); await this.authorizeTenantProofOfWork(body.did); } - private async getComplexity(): Promise { + private async getComplexity(): Promise { const result = await this.#db .selectFrom('authorizedTenants') .where('proofOfWorkTime', '>', Date.now() - COMPLEXITY_LOOKBACK) .select((eb) => eb.fn.countAll().as('recent_reg_count')) .executeTakeFirstOrThrow(); - const recent = result.recent_reg_count as number; - if (recent == 0) { + const recentRegistrationCount = result.recent_reg_count as number; + if (recentRegistrationCount == 0) { return COMPLEXITY_MINIMUM; } - const complexity = Math.floor(recent / 10); + const complexity = COMPLEXITY_MINIMUM / BigInt(recentRegistrationCount); if (complexity < COMPLEXITY_MINIMUM) { return COMPLEXITY_MINIMUM; } diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index c5461db..67f1730 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -1,3 +1,5 @@ +import { ProofOfWork } from "./proof-of-work.js"; + export class ProofOfWorkManager { private proofOfWorkOfLastMinute: Map = new Map(); // proofOfWorkId -> timestamp of proof-of-work private currentMaximumHashValueAsBigInt: bigint; @@ -34,6 +36,21 @@ export class ProofOfWorkManager { return proofOfWorkManager; } + public async verifyProofOfWork(proofOfWork: { + challenge: string; + responseNonce: string; + requestData: string; + }): Promise { + // REMINDER: verify challenge is not expired + + ProofOfWork.verifyChallengeResponse({ + challenge: proofOfWork.challenge, + responseNonce: proofOfWork.responseNonce, + requestData: proofOfWork.requestData, + maximumAllowedHashValue: this.currentMaximumAllowedHashValue, + }); + } + public start(): void { this.periodicallyRefreshProofOfWorkDifficulty(); } @@ -70,7 +87,7 @@ export class ProofOfWorkManager { * The difficulty will never be lower than the initial difficulty. */ private hashValueIncrementPerEvaluation = BigInt(1); - async refreshMaximumAllowedHashValue (): Promise { + public async refreshMaximumAllowedHashValue (): Promise { // Cleanup proof-of-work cache and update solve rate. this.removeProofOfWorkOlderThanOneMinute(); diff --git a/src/registration/proof-of-work-types.ts b/src/registration/proof-of-work-types.ts new file mode 100644 index 0000000..e1eb439 --- /dev/null +++ b/src/registration/proof-of-work-types.ts @@ -0,0 +1,4 @@ +export type ProofOfWorkChallengeModel = { + challenge: string; + complexity: string; +}; \ No newline at end of file diff --git a/src/registration/proof-of-work.ts b/src/registration/proof-of-work.ts index a69a5b8..22522c8 100644 --- a/src/registration/proof-of-work.ts +++ b/src/registration/proof-of-work.ts @@ -20,40 +20,35 @@ export class ProofOfWork { } public static verifyChallengeResponse(input: { - requiredLeadingZerosInResultingHash: number; + maximumAllowedHashValue: bigint; challenge: string; responseNonce: string; requestData?: string; }): void { const computedHash = this.computeHash(input); + const computedHashAsBigInt = BigInt(`0x${computedHash}`); - const hasSufficientLeadingZeros = computedHash.startsWith( - '0'.repeat(input.requiredLeadingZerosInResultingHash), - ); - - if (!hasSufficientLeadingZeros) { + if (computedHashAsBigInt > input.maximumAllowedHashValue) { throw new DwnServerError( - DwnServerErrorCode.ProofOfWorkInsufficientLeadingZeros, - `Insufficient leading zeros for computed hash ${computedHash}, needs ${input.requiredLeadingZerosInResultingHash} zeros.`, + DwnServerErrorCode.ProofOfWorkInsufficientSolutionNonce, + `Insufficient computed hash ${computedHashAsBigInt}, needs to be <= ${input.maximumAllowedHashValue}.`, ); } } public static findQualifiedNonce(input: { - requiredLeadingZerosInResultingHash: number; + maximumAllowedHashValue: string; challenge: string; requestData?: string; }): string { const startTime = Date.now(); - const { requiredLeadingZerosInResultingHash, challenge, requestData } = - input; - - const requiredHashPrefix = '0'.repeat(requiredLeadingZerosInResultingHash); + const { maximumAllowedHashValue, challenge, requestData } = input; + const maximumAllowedHashValueAsBigInt = BigInt(`0x${maximumAllowedHashValue}`); let iterations = 1; let randomNonce; - let hasSufficientLeadingZeros = false; + let qualifiedSolutionNonceFound = false; do { randomNonce = this.generateNonce(); const computedHash = this.computeHash({ @@ -61,8 +56,9 @@ export class ProofOfWork { responseNonce: randomNonce, requestData, }); + const computedHashAsBigInt = BigInt(`0x${computedHash}`); - hasSufficientLeadingZeros = computedHash.startsWith(requiredHashPrefix); + qualifiedSolutionNonceFound = computedHashAsBigInt <= maximumAllowedHashValueAsBigInt; iterations++; @@ -74,7 +70,7 @@ export class ProofOfWork { } ms`, ); } - } while (!hasSufficientLeadingZeros); + } while (!qualifiedSolutionNonceFound); // Log final/successful attempt. console.log( diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts new file mode 100644 index 0000000..65134df --- /dev/null +++ b/src/registration/registration-manager.ts @@ -0,0 +1,43 @@ +import { ProofOfWorkManager } from "./proof-of-work-manager.js"; + +type RegistrationRequest = { + proofOfWork: { + challenge: string; + responseNonce: string; + }, + registrationData: { + did: string; + termsOfServiceHash: string; + } +} + +export class RegistrationManager { + private proofOfWorkManager: ProofOfWorkManager; + + private constructor () { + } + + public static async create( + ): Promise { + const proofOfWorkManager = new RegistrationManager(); + proofOfWorkManager.proofOfWorkManager = await ProofOfWorkManager.create(10, '0FFFFFFFFFFFFFFF'); + + return proofOfWorkManager; + } + + public async handleRegistrationRequest(registrationRequest: RegistrationRequest): Promise { + this.proofOfWorkManager.verifyProofOfWork({ + challenge: registrationRequest.proofOfWork.challenge, + responseNonce: registrationRequest.proofOfWork.responseNonce, + requestData: JSON.stringify(registrationRequest.registrationData), + }); + + // Ensure the supplied terms of service hash matches the one we require. + if (registrationRequest.registrationData.termsOfServiceHash !== '') { + throw new Error('Invalid terms of service hash.'); + } + + // Store tenant registration data in database. + // await this.tenantRegistrationStore.storeTenantRegistration(registrationRequest.registrationData); + } +} diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 8b6d7fc..b2b4f86 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -38,6 +38,7 @@ import { getFileAsReadStream, streamHttpRequest, } from './utils.js'; +import type { ProofOfWorkChallengeModel } from '../src/registration/proof-of-work-types.js'; if (!globalThis.crypto) { // @ts-ignore @@ -93,23 +94,20 @@ describe('http api', function () { complexity: number; }; expect(body.challenge.length).to.equal(16); - expect(body.complexity).to.equal(5); + expect(body.complexity).to.equal('00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'); }); it('accepts a correct registration challenge', async function () { const challengeResponse = await fetch(proofOfWorkUrl); expect(challengeResponse.status).to.equal(200); - const body = (await challengeResponse.json()) as { - challenge: string; - complexity: number; - }; + const body = await challengeResponse.json() as ProofOfWorkChallengeModel; expect(body.challenge.length).to.equal(16); - expect(body.complexity).to.equal(5); + expect(body.complexity).to.equal('00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'); // solve the challenge const qualifiedNonce = ProofOfWork.findQualifiedNonce({ challenge: body.challenge, - requiredLeadingZerosInResultingHash: body.complexity, + maximumAllowedHashValue: body.complexity, }); const p = await DidKeyResolver.generate(); @@ -123,6 +121,8 @@ describe('http api', function () { }), }); + const text = await submitResponse.text(); + console.log(text); expect(submitResponse.status).to.equal(200); await tenantGate.authorizeTenantTermsOfService(p.did); @@ -152,12 +152,9 @@ describe('http api', function () { it('rejects a registration challenge 5 minutes after it was issued', async function () { const challengeResponse = await fetch(proofOfWorkUrl); expect(challengeResponse.status).to.equal(200); - const body = (await challengeResponse.json()) as { - challenge: string; - complexity: number; - }; + const body = await challengeResponse.json() as ProofOfWorkChallengeModel; expect(body.challenge.length).to.equal(16); - expect(body.complexity).to.equal(5); + expect(body.complexity).to.equal('00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'); clock.tick(5 * 60 * 1000); clock.runToLast(); @@ -165,7 +162,7 @@ describe('http api', function () { // solve the challenge const qualifiedNonce = ProofOfWork.findQualifiedNonce({ challenge: body.challenge, - requiredLeadingZerosInResultingHash: body.complexity, + maximumAllowedHashValue: body.complexity, }); const p = await DidKeyResolver.generate(); @@ -192,16 +189,13 @@ describe('http api', function () { const p = await DidKeyResolver.generate(); const challengeResponse = await fetch(proofOfWorkUrl); expect(challengeResponse.status).to.equal(200); - const body = (await challengeResponse.json()) as { - challenge: string; - complexity: number; - }; + const body = await challengeResponse.json() as ProofOfWorkChallengeModel; expect(body.challenge.length).to.equal(16); // solve the challenge const qualifiedNonce = ProofOfWork.findQualifiedNonce({ challenge: body.challenge, - requiredLeadingZerosInResultingHash: body.complexity, + maximumAllowedHashValue: body.complexity, }); const submitResponse = await fetch(proofOfWorkUrl, { @@ -246,7 +240,7 @@ describe('http api', function () { // solve the challenge const qualifiedNonce = ProofOfWork.findQualifiedNonce({ challenge: unknownChallenge, - requiredLeadingZerosInResultingHash: 1, + maximumAllowedHashValue: '00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', }); const p = await DidKeyResolver.generate(); diff --git a/tests/registration/proof-of-work-manager.spec.ts b/tests/registration/proof-of-work-manager.spec.ts index 554ffae..b8a4507 100644 --- a/tests/registration/proof-of-work-manager.spec.ts +++ b/tests/registration/proof-of-work-manager.spec.ts @@ -52,11 +52,6 @@ describe('ProofOfWorkManager', function () { for (let i = 0; i < 100; i++) { // Simulating no proof-of-work load for 100 seconds. - - console.log(`iteration: ${i}`); - console.log(`maximumAllowedHashValue: ${proofOfWorkManager.currentMaximumAllowedHashValue}`); - console.log(`currentSolveCountPerMinute: ${proofOfWorkManager.currentSolveCountPerMinute}`); - clock.tick(1000); await proofOfWorkManager.refreshMaximumAllowedHashValue(); From 1ba211eba35f334cf74759e7e09c337b0160df39 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Mon, 8 Jan 2024 14:48:00 -0800 Subject: [PATCH 10/27] Integrated RegistrationStore with RegistrationManager + added main-line scenario tests --- package-lock.json | 8 +- package.json | 2 +- src/dwn-error.ts | 13 + src/dwn-server.ts | 25 +- src/http-api.ts | 29 +- src/registered-tenant-gate.ts | 59 +-- src/registration/proof-of-work-manager.ts | 88 ++++- src/registration/proof-of-work-types.ts | 4 +- src/registration/proof-of-work.ts | 48 +-- src/registration/registration-manager.ts | 79 ++-- src/registration/registration-store.ts | 50 +++ src/registration/registration-types.ts | 12 + tests/http-api.spec.ts | 227 +---------- tests/process-handler.spec.ts | 2 + .../proof-of-work-manager.spec.ts | 10 +- tests/scenarios/registration.spec.ts | 367 ++++++++++++++++++ 16 files changed, 643 insertions(+), 380 deletions(-) create mode 100644 src/registration/registration-store.ts create mode 100644 src/registration/registration-types.ts create mode 100644 tests/scenarios/registration.spec.ts diff --git a/package-lock.json b/package-lock.json index 9812720..1302bf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@web5/dwn-server", "version": "0.1.8", "dependencies": { - "@tbd54566975/dwn-sdk-js": "0.2.10", + "@tbd54566975/dwn-sdk-js": "0.2.11", "@tbd54566975/dwn-sql-store": "0.2.5", "better-sqlite3": "^8.5.0", "body-parser": "^1.20.2", @@ -569,9 +569,9 @@ "dev": true }, "node_modules/@tbd54566975/dwn-sdk-js": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.2.10.tgz", - "integrity": "sha512-CoKO8+NciwWNzD4xRoAAgeElqQCXKM4Fc+zEHsUWD0M3E9v67hRWiTHI6AenUfQv1RSEB2H4GHUeUOHuEV72uw==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.2.11.tgz", + "integrity": "sha512-trFMhmCsyjCcrCFsaRuzpTPYyRQWVmi+5AD2mgWVwMz7hMC98dzXTVftDx3dZNZ3CwQlJe+4ts9JhVys+4q4Sg==", "dependencies": { "@ipld/dag-cbor": "9.0.3", "@js-temporal/polyfill": "0.4.4", diff --git a/package.json b/package.json index 95deace..3bcbcd9 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "url": "https://github.com/TBD54566975/dwn-server/issues" }, "dependencies": { - "@tbd54566975/dwn-sdk-js": "0.2.10", + "@tbd54566975/dwn-sdk-js": "0.2.11", "@tbd54566975/dwn-sql-store": "0.2.5", "better-sqlite3": "^8.5.0", "body-parser": "^1.20.2", diff --git a/src/dwn-error.ts b/src/dwn-error.ts index 9545717..1db2da7 100644 --- a/src/dwn-error.ts +++ b/src/dwn-error.ts @@ -10,6 +10,16 @@ export class DwnServerError extends Error { this.name = 'DwnServerError'; } + + /** + * Called by `JSON.stringify(...)` automatically. + */ + public toJSON(): { code: string, message: string } { + return { + code: this.code, + message: this.message, + }; + } } /** @@ -18,5 +28,8 @@ export class DwnServerError extends Error { export enum DwnServerErrorCode { ProofOfWorkInsufficientSolutionNonce = 'ProofOfWorkInsufficientSolutionNonce', ProofOfWorkInvalidOrExpiredChallenge = 'ProofOfWorkInvalidOrExpiredChallenge', + ProofOfWorkManagerInvalidChallengeNonce = 'ProofOfWorkManagerInvalidChallengeNonce', + ProofOfWorkManagerInvalidResponseNonceFormat = 'ProofOfWorkManagerInvalidResponseNonceFormat', + RegistrationManagerInvalidOrOutdatedTermsOfServiceHash = 'RegistrationManagerInvalidOrOutdatedTermsOfServiceHash', TenantRegistrationOutdatedTermsOfService = 'TenantRegistrationOutdatedTermsOfService', } diff --git a/src/dwn-server.ts b/src/dwn-server.ts index 7c45105..31f70c3 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -14,6 +14,7 @@ import { setProcessHandlers } from './process-handlers.js'; import { RegisteredTenantGate } from './registered-tenant-gate.js'; import { getDWNConfig, getDialectFromURI } from './storage.js'; import { WsApi } from './ws-api.js'; +import { RegistrationManager } from './registration/registration-manager.js'; export type DwnServerOptions = { dwn?: Dwn; @@ -48,28 +49,32 @@ export class DwnServer { * The DWN creation is secondary and only happens if it hasn't already been done. */ async #setupServer(): Promise { + // Load terms of service if given the path. + const termsOfService = + this.config.termsOfServiceFilePath !== undefined + ? readFileSync(this.config.termsOfServiceFilePath).toString() + : undefined; + + const tenantGateDB = getDialectFromURI( + new URL(this.config.tenantRegistrationStore), + ); + let tenantGate: RegisteredTenantGate; + let registrationManager: RegistrationManager; if (!this.dwn) { - const tenantGateDB = getDialectFromURI( - new URL(this.config.tenantRegistrationStore), - ); - - // Load terms of service if given the path. - const termsOfService = - this.config.termsOfServiceFilePath !== undefined - ? readFileSync(this.config.termsOfServiceFilePath).toString() - : undefined; tenantGate = new RegisteredTenantGate( tenantGateDB, this.config.registrationProofOfWorkEnabled, termsOfService, ); + registrationManager = await RegistrationManager.create({ sqlDialect: tenantGateDB, termsOfService }); this.dwn = await Dwn.create(getDWNConfig(this.config, tenantGate)); } - this.#httpApi = new HttpApi(this.config, this.dwn, tenantGate); + this.#httpApi = new HttpApi(this.config, this.dwn, tenantGate, registrationManager); + await this.#httpApi.start(this.config.port, () => { log.info(`HttpServer listening on port ${this.config.port}`); }); diff --git a/src/http-api.ts b/src/http-api.ts index 7a2c8c3..31f5fa0 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -32,12 +32,13 @@ export class HttpApi { registrationManager: RegistrationManager; dwn: Dwn; - constructor(config: Config, dwn: Dwn, tenantGate: RegisteredTenantGate) { + constructor(config: Config, dwn: Dwn, tenantGate: RegisteredTenantGate, registrationManager: RegistrationManager) { this.#config = config; this.#api = express(); this.#server = http.createServer(this.#api); this.dwn = dwn; this.tenantGate = tenantGate; + this.registrationManager = registrationManager; this.#setupMiddleware(); this.#setupRoutes(); @@ -195,28 +196,9 @@ export class HttpApi { #setupRegistrationRoutes(): void { if (this.#config.registrationProofOfWorkEnabled) { this.#api.get('/register/proof-of-work', async (_req: Request, res: Response) => { - const proofOfWorkChallenge = await this.tenantGate.getProofOfWorkChallenge(); + const proofOfWorkChallenge = await this.registrationManager.getProofOfWorkChallenge(); res.json(proofOfWorkChallenge); }); - - this.#api.post('/register/proof-of-work', async (req: Request, res: Response) => { - try { - await this.tenantGate.handleProofOfWorkChallengePost(req.body); - res.json({ success: true }); - } catch (error) { - const dwnServerError = error as DwnServerError; - - if (dwnServerError.code !== undefined) { - res.status(401).json({ - success : false, - reason : dwnServerError.message, - }); - } else { - console.log('Error handling proof-of-work POST:', error); - res.status(500).json({ success: false }); - } - } - }); } if (this.#config.termsOfServiceFilePath !== undefined) { this.#api.get('/register/terms-of-service', (_req: Request, res: Response) => res.send(this.tenantGate.termsOfService)); @@ -248,10 +230,7 @@ export class HttpApi { const dwnServerError = error as DwnServerError; if (dwnServerError.code !== undefined) { - res.status(400).json({ - success : false, - reason : dwnServerError.message, - }); + res.status(400).json(dwnServerError); } else { console.log('Error handling registration request:', error); res.status(500).json({ success: false }); diff --git a/src/registered-tenant-gate.ts b/src/registered-tenant-gate.ts index 429b176..9a5e5eb 100644 --- a/src/registered-tenant-gate.ts +++ b/src/registered-tenant-gate.ts @@ -1,15 +1,13 @@ import type { TenantGate } from '@tbd54566975/dwn-sdk-js'; -import { createHash, randomBytes } from 'crypto'; import type { Dialect } from 'kysely'; import { Kysely } from 'kysely'; import { DwnServerError } from './dwn-error.js'; import { DwnServerErrorCode } from './dwn-error.js'; import { ProofOfWork } from './registration/proof-of-work.js'; -import type { ProofOfWorkChallengeModel } from './registration/proof-of-work-types.js'; -const recentChallenges: { [challenge: string]: number } = {}; +const recentChallenges: { [challengeNonce: string]: number } = {}; const CHALLENGE_TIMEOUT = 5 * 60 * 1000; // challenges are valid this long after issuance const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this time frame const COMPLEXITY_MINIMUM = BigInt('0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'); @@ -29,9 +27,7 @@ export class RegisteredTenantGate implements TenantGate { this.#proofOfWorkRequired = proofOfWorkRequired; if (termsOfService) { - const termsOfServiceHash = createHash('sha256'); - termsOfServiceHash.update(termsOfService); - this.#termsOfServiceHash = termsOfServiceHash.digest('hex'); + this.#termsOfServiceHash = ProofOfWork.hashAsHexString([termsOfService]); this.#termsOfService = termsOfService; } } @@ -54,7 +50,7 @@ export class RegisteredTenantGate implements TenantGate { .execute(); } - async isTenant(tenant: string): Promise { + async isActiveTenant(tenant: string): Promise { if (!this.#proofOfWorkRequired && !this.#termsOfService) { return true; } @@ -81,55 +77,6 @@ export class RegisteredTenantGate implements TenantGate { return true; } - async authorizeTenantProofOfWork(tenant: string): Promise { - await this.#db - .insertInto('authorizedTenants') - .values({ - did: tenant, - proofOfWorkTime: Date.now(), - }) - .onConflict((oc) => - oc.column('did').doUpdateSet((eb) => ({ - proofOfWorkTime: eb.ref('excluded.proofOfWorkTime'), - })), - ) - .executeTakeFirst(); - } - - async getProofOfWorkChallenge(): Promise { - const challenge = randomBytes(10).toString('base64'); - recentChallenges[challenge] = Date.now(); - return { - challenge: challenge, - complexity: this.bigIntToHexString(await this.getComplexity()), - }; - } - - private bigIntToHexString (int: BigInt): string { - let hex = int.toString(16).toUpperCase(); - const stringLength = hex.length; - for (let pad = stringLength; pad < 64; pad++) { - hex = '0' + hex; - } - return hex; - } - - async handleProofOfWorkChallengePost(body: { did: string; challenge: string; response: string }): Promise { - const challengeIssued = recentChallenges[body.challenge]; - - if (challengeIssued == undefined || Date.now() - challengeIssued > CHALLENGE_TIMEOUT) { - throw new DwnServerError(DwnServerErrorCode.ProofOfWorkInvalidOrExpiredChallenge, `Invalid or expired challenge: ${body.challenge}.`); - } - - ProofOfWork.verifyChallengeResponse({ - challenge: body.challenge, - responseNonce: body.response, - maximumAllowedHashValue: await this.getComplexity(), - }); - - await this.authorizeTenantProofOfWork(body.did); - } - private async getComplexity(): Promise { const result = await this.#db .selectFrom('authorizedTenants') diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index 67f1730..a6e4155 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -1,12 +1,16 @@ +import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; +import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; import { ProofOfWork } from "./proof-of-work.js"; export class ProofOfWorkManager { + private challengeNonces: { currentChallengeNonce: string, previousChallengeNonce?: string }; private proofOfWorkOfLastMinute: Map = new Map(); // proofOfWorkId -> timestamp of proof-of-work private currentMaximumHashValueAsBigInt: bigint; private initialMaximumHashValueAsBigInt: bigint; private desiredSolveCountPerMinute: number; - static readonly #difficultyReevaluationFrequencyInMilliseconds = 10000; + static readonly challengeRefreshFrequencyInMilliseconds = 10 * 60 * 1000; // 10 minutes + static readonly difficultyReevaluationFrequencyInMilliseconds = 10000; public get currentMaximumAllowedHashValue(): bigint { return this.currentMaximumHashValueAsBigInt; @@ -17,41 +21,80 @@ export class ProofOfWorkManager { } private constructor (desiredSolveCountPerMinute: number, initialMaximumHashValue: string) { + this.challengeNonces = { currentChallengeNonce: ProofOfWork.generateNonce() }; this.currentMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); this.initialMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); this.desiredSolveCountPerMinute = desiredSolveCountPerMinute; } - public static async create( + public static async create(input: { desiredSolveCountPerMinute: number, initialMaximumHashValue: string, - autoStart: boolean = false - ): Promise { - const proofOfWorkManager = new ProofOfWorkManager(desiredSolveCountPerMinute, initialMaximumHashValue); + autoStart: boolean, + }): Promise { + const proofOfWorkManager = new ProofOfWorkManager(input.desiredSolveCountPerMinute, input.initialMaximumHashValue); - if (autoStart) { + if (input.autoStart) { proofOfWorkManager.start(); } return proofOfWorkManager; } + public getProofOfWorkChallenge(): ProofOfWorkChallengeModel { + return { + challengeNonce: this.challengeNonces.currentChallengeNonce, + maximumAllowedHashValue: ProofOfWorkManager.bigIntToHexString(this.currentMaximumAllowedHashValue), + }; + } + + /** + * Converts a BigInt to a 256 bit HEX string with padded preceding zeros (64 characters). + */ + private static bigIntToHexString (int: BigInt): string { + let hex = int.toString(16).toUpperCase(); + const stringLength = hex.length; + for (let pad = stringLength; pad < 64; pad++) { + hex = '0' + hex; + } + return hex; + } + + public static isHexString(str: string): boolean { + const regexp = /^[0-9a-fA-F]+$/; + return regexp.test(str); + } + public async verifyProofOfWork(proofOfWork: { - challenge: string; + challengeNonce: string; responseNonce: string; requestData: string; }): Promise { - // REMINDER: verify challenge is not expired - - ProofOfWork.verifyChallengeResponse({ - challenge: proofOfWork.challenge, - responseNonce: proofOfWork.responseNonce, - requestData: proofOfWork.requestData, - maximumAllowedHashValue: this.currentMaximumAllowedHashValue, - }); + const { challengeNonce, responseNonce, requestData } = proofOfWork; + + // Verify response nonce is a HEX string that represents a 256 bit value. + if (!ProofOfWorkManager.isHexString(responseNonce) || responseNonce.length !== 64) { + throw new DwnServerError( + DwnServerErrorCode.ProofOfWorkManagerInvalidResponseNonceFormat, + `Response nonce not a HEX string representing a 256 bit value: ${responseNonce}.` + ); + } + + // Verify challenge nonce is valid. + if (challengeNonce !== this.challengeNonces.currentChallengeNonce && + challengeNonce !== this.challengeNonces.previousChallengeNonce) { + throw new DwnServerError( + DwnServerErrorCode.ProofOfWorkManagerInvalidChallengeNonce, + `Unknown or expired challenge nonce: ${challengeNonce}.` + ); + } + + const maximumAllowedHashValue = this.currentMaximumAllowedHashValue; + ProofOfWork.verifyResponseNonce({ challengeNonce, responseNonce, requestData, maximumAllowedHashValue }); } public start(): void { + this.periodicallyRefreshChallengeNonce(); this.periodicallyRefreshProofOfWorkDifficulty(); } @@ -59,13 +102,24 @@ export class ProofOfWorkManager { this.proofOfWorkOfLastMinute.set(proofOfWorkId, Date.now()); } + private periodicallyRefreshChallengeNonce (): void { + try { + this.challengeNonces.previousChallengeNonce = this.challengeNonces.currentChallengeNonce; + this.challengeNonces.currentChallengeNonce = ProofOfWork.generateNonce(); + } catch (error) { + console.error(`Encountered error while refreshing challenge nonce: ${error}`); + } finally { + setTimeout(async () => this.periodicallyRefreshChallengeNonce(), ProofOfWorkManager.challengeRefreshFrequencyInMilliseconds); + } + } + private periodicallyRefreshProofOfWorkDifficulty (): void { try { this.refreshMaximumAllowedHashValue(); } catch (error) { console.error(`Encountered error while updating proof of work difficulty: ${error}`); } finally { - setTimeout(async () => this.periodicallyRefreshProofOfWorkDifficulty(), ProofOfWorkManager.#difficultyReevaluationFrequencyInMilliseconds); + setTimeout(async () => this.periodicallyRefreshProofOfWorkDifficulty(), ProofOfWorkManager.difficultyReevaluationFrequencyInMilliseconds); } } @@ -95,7 +149,7 @@ export class ProofOfWorkManager { // NOTE: bigint arithmetic does NOT work with decimals, so we work with "full numbers" by multiplying by a scale factor. const scaleFactor = 1_000_000; - const difficultyEvaluationsPerMinute = 60000 / ProofOfWorkManager.#difficultyReevaluationFrequencyInMilliseconds; // assumed to be >= 1; + const difficultyEvaluationsPerMinute = 60000 / ProofOfWorkManager.difficultyReevaluationFrequencyInMilliseconds; // assumed to be >= 1; // NOTE: easier difficulty is represented by a larger max allowed hash value // and harder difficulty is represented by a smaller max allowed hash value. diff --git a/src/registration/proof-of-work-types.ts b/src/registration/proof-of-work-types.ts index e1eb439..86939a8 100644 --- a/src/registration/proof-of-work-types.ts +++ b/src/registration/proof-of-work-types.ts @@ -1,4 +1,4 @@ export type ProofOfWorkChallengeModel = { - challenge: string; - complexity: string; + challengeNonce: string; + maximumAllowedHashValue: string; }; \ No newline at end of file diff --git a/src/registration/proof-of-work.ts b/src/registration/proof-of-work.ts index 22522c8..f7a0464 100644 --- a/src/registration/proof-of-work.ts +++ b/src/registration/proof-of-work.ts @@ -1,27 +1,34 @@ -import { createHash } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import { DwnServerError, DwnServerErrorCode } from '../dwn-error.js'; export class ProofOfWork { public static computeHash(input: { - challenge: string; + challengeNonce: string; responseNonce: string; requestData?: string; }): string { - const hash = createHash('sha256'); - hash.update(input.challenge); - hash.update(input.responseNonce); + const hashInput = [input.challengeNonce, input.responseNonce]; - if (input.requestData !== undefined) { - hash.update(input.requestData); + if (input.requestData) { + hashInput.push(input.requestData); + } + + return this.hashAsHexString(hashInput); + } + + public static hashAsHexString(input: string[]): string { + const hash = createHash('sha256'); + for (const item of input) { + hash.update(item); } return hash.digest('hex'); } - public static verifyChallengeResponse(input: { + public static verifyResponseNonce(input: { maximumAllowedHashValue: bigint; - challenge: string; + challengeNonce: string; responseNonce: string; requestData?: string; }): void { @@ -36,14 +43,14 @@ export class ProofOfWork { } } - public static findQualifiedNonce(input: { + public static findQualifiedResponseNonce(input: { maximumAllowedHashValue: string; - challenge: string; + challengeNonce: string; requestData?: string; }): string { const startTime = Date.now(); - const { maximumAllowedHashValue, challenge, requestData } = input; + const { maximumAllowedHashValue, challengeNonce, requestData } = input; const maximumAllowedHashValueAsBigInt = BigInt(`0x${maximumAllowedHashValue}`); let iterations = 1; @@ -52,7 +59,7 @@ export class ProofOfWork { do { randomNonce = this.generateNonce(); const computedHash = this.computeHash({ - challenge, + challengeNonce, responseNonce: randomNonce, requestData, }); @@ -80,14 +87,11 @@ export class ProofOfWork { return randomNonce; } - public static generateNonce(size: number = 32): string { - const nonceChars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - - let nonce = ''; - while (nonce.length < size) { - nonce += nonceChars.charAt(Math.floor(Math.random() * nonceChars.length)); - } - return nonce; + /** + * Generates 32 random bytes expressed as a HEX string. + */ + public static generateNonce(): string { + const hexString = randomBytes(32).toString('hex').toUpperCase(); + return hexString; } } diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts index 65134df..02e9269 100644 --- a/src/registration/registration-manager.ts +++ b/src/registration/registration-manager.ts @@ -1,43 +1,72 @@ +import type { Dialect } from "@tbd54566975/dwn-sql-store"; import { ProofOfWorkManager } from "./proof-of-work-manager.js"; - -type RegistrationRequest = { - proofOfWork: { - challenge: string; - responseNonce: string; - }, - registrationData: { - did: string; - termsOfServiceHash: string; - } -} +import { ProofOfWork } from "./proof-of-work.js"; +import { RegistrationStore } from "./registration-store.js"; +import type { RegistrationRequest } from "./registration-types.js"; +import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; +import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; export class RegistrationManager { private proofOfWorkManager: ProofOfWorkManager; + private registrationStore: RegistrationStore; + + private termsOfServiceHash?: string; + private termsOfService?: string; + + public getTermsOfService(): string { + return this.termsOfService; + } - private constructor () { + private constructor (termsOfService?: string) { + if (termsOfService) { + this.termsOfServiceHash = ProofOfWork.hashAsHexString([termsOfService]); + this.termsOfService = termsOfService; + } } - public static async create( - ): Promise { - const proofOfWorkManager = new RegistrationManager(); - proofOfWorkManager.proofOfWorkManager = await ProofOfWorkManager.create(10, '0FFFFFFFFFFFFFFF'); + public static async create(input: { + sqlDialect: Dialect, + termsOfService?: string + }): Promise { + const { termsOfService, sqlDialect } = input; + + // Initialize and start ProofOfWorkManager. + const proofOfWorkManager = new RegistrationManager(termsOfService); + proofOfWorkManager.proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: true, + desiredSolveCountPerMinute: 10, + initialMaximumHashValue: '00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + }); + + // Initialize RegistrationStore. + proofOfWorkManager.registrationStore = await RegistrationStore.create(sqlDialect); return proofOfWorkManager; } - public async handleRegistrationRequest(registrationRequest: RegistrationRequest): Promise { - this.proofOfWorkManager.verifyProofOfWork({ - challenge: registrationRequest.proofOfWork.challenge, - responseNonce: registrationRequest.proofOfWork.responseNonce, - requestData: JSON.stringify(registrationRequest.registrationData), - }); + public getProofOfWorkChallenge(): ProofOfWorkChallengeModel { + const proofOfWorkChallenge = this.proofOfWorkManager.getProofOfWorkChallenge(); + return proofOfWorkChallenge; + } + + public async handleRegistrationRequest(registrationRequest: RegistrationRequest): Promise { // Ensure the supplied terms of service hash matches the one we require. - if (registrationRequest.registrationData.termsOfServiceHash !== '') { - throw new Error('Invalid terms of service hash.'); + if (registrationRequest.registrationData.termsOfServiceHash !== this.termsOfServiceHash) { + throw new DwnServerError(DwnServerErrorCode.RegistrationManagerInvalidOrOutdatedTermsOfServiceHash, + `Expecting terms-of-service hash ${this.termsOfServiceHash}, but got ${registrationRequest.registrationData.termsOfServiceHash}.` + ); } + const { challengeNonce, responseNonce } = registrationRequest.proofOfWork; + + await this.proofOfWorkManager.verifyProofOfWork({ + challengeNonce, + responseNonce, + requestData: JSON.stringify(registrationRequest.registrationData), + }); + // Store tenant registration data in database. - // await this.tenantRegistrationStore.storeTenantRegistration(registrationRequest.registrationData); + await this.registrationStore.insertOrUpdateTenantRegistration(registrationRequest.registrationData); } } diff --git a/src/registration/registration-store.ts b/src/registration/registration-store.ts new file mode 100644 index 0000000..60e7ffb --- /dev/null +++ b/src/registration/registration-store.ts @@ -0,0 +1,50 @@ +import { Kysely } from 'kysely'; +import type { RegistrationData } from './registration-types.js'; +import type { Dialect } from '@tbd54566975/dwn-sql-store'; + +export class RegistrationStore { + private db: Kysely; + + private constructor (sqlDialect: Dialect) { + this.db = new Kysely({ dialect: sqlDialect }); + } + + public static async create(sqlDialect: Dialect): Promise { + const proofOfWorkManager = new RegistrationStore(sqlDialect); + + await proofOfWorkManager.initialize(); + + return proofOfWorkManager; + } + + private async initialize(): Promise { + await this.db.schema + .createTable('authorizedTenants') + .ifNotExists() + .addColumn('did', 'text', (column) => column.primaryKey()) + .addColumn('termsOfServiceHash', 'boolean') + .execute(); + } + + public async insertOrUpdateTenantRegistration(registrationData: RegistrationData): Promise { + await this.db + .insertInto('authorizedTenants') + .values(registrationData) + .onConflict((oc) => + oc.column('did').doUpdateSet((eb) => ({ + termsOfServiceHash: eb.ref('excluded.termsOfServiceHash'), + })), + ) + .executeTakeFirst(); + } +} + +interface AuthorizedTenants { + did: string; + termsOfServiceHash: string; + proofOfWorkTime: number; +} + +interface RegistrationDatabase { + authorizedTenants: AuthorizedTenants; +} diff --git a/src/registration/registration-types.ts b/src/registration/registration-types.ts new file mode 100644 index 0000000..6a27c91 --- /dev/null +++ b/src/registration/registration-types.ts @@ -0,0 +1,12 @@ +export type RegistrationData = { + did: string; + termsOfServiceHash: string; +}; + +export type RegistrationRequest = { + proofOfWork: { + challengeNonce: string; + responseNonce: string; + }, + registrationData: RegistrationData +}; \ No newline at end of file diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index b2b4f86..a16f830 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -10,7 +10,6 @@ import { import type { Dwn } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; -import { createHash } from 'crypto'; import { readFileSync } from 'fs'; import type { Server } from 'http'; import fetch from 'node-fetch'; @@ -38,7 +37,8 @@ import { getFileAsReadStream, streamHttpRequest, } from './utils.js'; -import type { ProofOfWorkChallengeModel } from '../src/registration/proof-of-work-types.js'; +import { getDialectFromURI } from '../src/storage.js'; +import { RegistrationManager } from '../src/registration/registration-manager.js'; if (!globalThis.crypto) { // @ts-ignore @@ -50,6 +50,7 @@ describe('http api', function () { let server: Server; let profile: Profile; let tenantGate: RegisteredTenantGate; + let registrationManager: RegistrationManager; let dwn: Dwn; let clock; @@ -62,11 +63,14 @@ describe('http api', function () { dwn = testDwn.dwn; tenantGate = testDwn.tenantGate; - httpApi = new HttpApi(config, dwn, tenantGate); + const sqlDialect = getDialectFromURI(new URL('sqlite://')); + const termsOfService = readFileSync(config.termsOfServiceFilePath).toString(); + registrationManager = await RegistrationManager.create({ sqlDialect, termsOfService }); + + httpApi = new HttpApi(config, dwn, tenantGate, registrationManager); await tenantGate.initialize(); profile = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantProofOfWork(profile.did); await tenantGate.authorizeTenantTermsOfService(profile.did); }); @@ -83,207 +87,6 @@ describe('http api', function () { clock.restore(); }); - describe('/register/proof-of-work', function () { - const proofOfWorkUrl = 'http://localhost:3000/register/proof-of-work'; - - it('returns a register challenge', async function () { - const response = await fetch(proofOfWorkUrl); - expect(response.status).to.equal(200); - const body = (await response.json()) as { - challenge: string; - complexity: number; - }; - expect(body.challenge.length).to.equal(16); - expect(body.complexity).to.equal('00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'); - }); - - it('accepts a correct registration challenge', async function () { - const challengeResponse = await fetch(proofOfWorkUrl); - expect(challengeResponse.status).to.equal(200); - const body = await challengeResponse.json() as ProofOfWorkChallengeModel; - expect(body.challenge.length).to.equal(16); - expect(body.complexity).to.equal('00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'); - - // solve the challenge - const qualifiedNonce = ProofOfWork.findQualifiedNonce({ - challenge: body.challenge, - maximumAllowedHashValue: body.complexity, - }); - - const p = await DidKeyResolver.generate(); - const submitResponse = await fetch(proofOfWorkUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - challenge: body.challenge, - response: qualifiedNonce, - did: p.did, - }), - }); - - const text = await submitResponse.text(); - console.log(text); - expect(submitResponse.status).to.equal(200); - - await tenantGate.authorizeTenantTermsOfService(p.did); - - const recordsQuery = await RecordsQuery.create({ - filter: { schema: 'woosa' }, - signer: p.signer, - }); - - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: p.did, - }); - - const rpcResponse = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); - - console.log(rpcResponse.body.result.reply.status); - expect(rpcResponse.statusCode).to.equal(200); - expect(rpcResponse.body.id).to.equal(requestId); - expect(rpcResponse.body.result.reply.status.code).to.equal(200); - }).timeout(30000); - - it('rejects a registration challenge 5 minutes after it was issued', async function () { - const challengeResponse = await fetch(proofOfWorkUrl); - expect(challengeResponse.status).to.equal(200); - const body = await challengeResponse.json() as ProofOfWorkChallengeModel; - expect(body.challenge.length).to.equal(16); - expect(body.complexity).to.equal('00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'); - - clock.tick(5 * 60 * 1000); - clock.runToLast(); - - // solve the challenge - const qualifiedNonce = ProofOfWork.findQualifiedNonce({ - challenge: body.challenge, - maximumAllowedHashValue: body.complexity, - }); - - const p = await DidKeyResolver.generate(); - const submitResponse = await fetch(proofOfWorkUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - challenge: body.challenge, - response: qualifiedNonce, - did: p.did, - }), - }); - - expect(submitResponse.status).to.equal(401); - }).timeout(30000); - - it('increase complexity as more challenges are completed', async function () { - for (let i = 1; i <= 60; i++) { - tenantGate.authorizeTenantProofOfWork( - (await DidKeyResolver.generate()).did, - ); - } - - const p = await DidKeyResolver.generate(); - const challengeResponse = await fetch(proofOfWorkUrl); - expect(challengeResponse.status).to.equal(200); - const body = await challengeResponse.json() as ProofOfWorkChallengeModel; - expect(body.challenge.length).to.equal(16); - - // solve the challenge - const qualifiedNonce = ProofOfWork.findQualifiedNonce({ - challenge: body.challenge, - maximumAllowedHashValue: body.complexity, - }); - - const submitResponse = await fetch(proofOfWorkUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - challenge: body.challenge, - response: qualifiedNonce, - did: p.did, - }), - }); - - expect(submitResponse.status).to.equal(200); - }).timeout(120000); - - it('rejects an invalid nonce', async function () { - const challengeResponse = await fetch(proofOfWorkUrl); - expect(challengeResponse.status).to.equal(200); - const body = (await challengeResponse.json()) as { - challenge: string; - complexity: number; - }; - expect(body.challenge.length).to.equal(16); - - const p = await DidKeyResolver.generate(); - const submitResponse = await fetch(proofOfWorkUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - challenge: body.challenge, - response: 'insufficient-nonce', - did: p.did, - }), - }); - - expect(submitResponse.status).to.equal(401); - }); - - it('rejects a challenge it did not issue', async function () { - const unknownChallenge = 'unknown-challenge'; - - // solve the challenge - const qualifiedNonce = ProofOfWork.findQualifiedNonce({ - challenge: unknownChallenge, - maximumAllowedHashValue: '00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', - }); - - const p = await DidKeyResolver.generate(); - const submitResponse = await fetch(proofOfWorkUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - challenge: unknownChallenge, - response: qualifiedNonce, - did: p.did, - }), - }); - - expect(submitResponse.status).to.equal(401); - }); - - it('rejects tenants that have not accepted the terms of use and have not completed proof-of-work', async function () { - const unauthorized = await DidKeyResolver.generate(); - const recordsQuery = await RecordsQuery.create({ - filter: { schema: 'woosa' }, - signer: unauthorized.signer, - }); - - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: unauthorized.did, - }); - - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); - - expect(response.statusCode).to.equal(200); - expect(response.body.id).to.equal(requestId); - expect(response.body.result.reply.status.code).to.equal(401); - }); - - xit('rejects registration that have accepted the terms of use but not completed proof-of-work', async function () { - }); - }); - describe('/register/terms-of-service', function () { it('allow tenant that after accepting the terms of service', async function () { const response = await fetch( @@ -297,8 +100,7 @@ describe('http api', function () { readFileSync('./tests/fixtures/terms-of-service.txt').toString(), ); - const hash = createHash('sha256'); - hash.update(terms); + const termsOfServiceHash = ProofOfWork.hashAsHexString([terms]); const p = await DidKeyResolver.generate(); @@ -309,12 +111,11 @@ describe('http api', function () { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did: p.did, - termsOfServiceHash: hash.digest('hex'), + termsOfServiceHash }), }, ); expect(acceptResponse.status).to.equal(200); - await tenantGate.authorizeTenantProofOfWork(p.did); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, @@ -340,7 +141,6 @@ describe('http api', function () { it('rejects tenants that have completed proof-of-work but have not accepted the terms of use', async function () { const unauthorized = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantProofOfWork(unauthorized.did); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, signer: unauthorized.signer, @@ -363,8 +163,7 @@ describe('http api', function () { }); it('rejects terms of use acceptance with incorrect hash', async function () { - const hash = createHash('sha256'); - hash.update('i do not agree'); + const termsOfServiceHash = ProofOfWork.hashAsHexString(['i do not agree']); // incorrect hash const p = await DidKeyResolver.generate(); @@ -375,12 +174,11 @@ describe('http api', function () { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did: p.did, - termsOfServiceHash: hash.digest('hex'), + termsOfServiceHash }), }, ); expect(acceptResponse.status).to.equal(400); - await tenantGate.authorizeTenantProofOfWork(p.did); const recordsQuery = await RecordsQuery.create({ filter: { schema: 'woosa' }, @@ -555,7 +353,6 @@ describe('http api', function () { it('handles RecordsWrite overwrite that does not mutate data', async function () { const p = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantProofOfWork(p.did); await tenantGate.authorizeTenantTermsOfService(p.did); // First RecordsWrite that creates the record. diff --git a/tests/process-handler.spec.ts b/tests/process-handler.spec.ts index ba18a22..ad96e07 100644 --- a/tests/process-handler.spec.ts +++ b/tests/process-handler.spec.ts @@ -16,6 +16,8 @@ describe('Process Handlers', function () { processExitStub = sinon.stub(process, 'exit'); }); afterEach(async function () { + dwnServer.stop(() => console.log('server stop in Process Handlers tests')); + process.removeAllListeners('SIGINT'); process.removeAllListeners('SIGTERM'); process.removeAllListeners('uncaughtException'); diff --git a/tests/registration/proof-of-work-manager.spec.ts b/tests/registration/proof-of-work-manager.spec.ts index b8a4507..e4d5954 100644 --- a/tests/registration/proof-of-work-manager.spec.ts +++ b/tests/registration/proof-of-work-manager.spec.ts @@ -26,12 +26,16 @@ describe('ProofOfWorkManager', function () { describe('complexity', function () { it('should become more complex as more successful proof-of-work is submitted', async function () { - const desiredSolveRatePerMinute = 10; + const desiredSolveCountPerMinute = 10; const initialMaximumHashValue = 'FFFFFFFF'; - const proofOfWorkManager = await ProofOfWorkManager.create(desiredSolveRatePerMinute, initialMaximumHashValue); + const proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: false, + desiredSolveCountPerMinute, + initialMaximumHashValue, + }); // Load up desiredSolveRatePerMinute number of proof-of-work entries, so all future new entries will increase the complexity. - for (let i = 0; i < desiredSolveRatePerMinute; i++) { + for (let i = 0; i < desiredSolveCountPerMinute; i++) { await proofOfWorkManager.recordProofOfWork(uuidv4()); } diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts new file mode 100644 index 0000000..a922655 --- /dev/null +++ b/tests/scenarios/registration.spec.ts @@ -0,0 +1,367 @@ +// node.js 18 and earlier, needs globalThis.crypto polyfill +import { + DataStream, + DidKeyResolver, +} from '@tbd54566975/dwn-sdk-js'; +import type { Dwn, Persona } from '@tbd54566975/dwn-sdk-js'; + +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import type { Server } from 'http'; +import fetch from 'node-fetch'; +import { webcrypto } from 'node:crypto'; +import { useFakeTimers } from 'sinon'; +import { v4 as uuidv4 } from 'uuid'; + +import { config } from '../../src/config.js'; +import { HttpApi } from '../../src/http-api.js'; +import type { + JsonRpcRequest, + JsonRpcResponse, +} from '../../src/lib/json-rpc.js'; +import { + createJsonRpcRequest, +} from '../../src/lib/json-rpc.js'; +import type { RegisteredTenantGate } from '../../src/registered-tenant-gate.js'; +import { ProofOfWork } from '../../src/registration/proof-of-work.js'; +import { getTestDwn } from '../test-dwn.js'; +import { + createRecordsWriteMessage, +} from '../utils.js'; +import type { ProofOfWorkChallengeModel } from '../../src/registration/proof-of-work-types.js'; +import type { RegistrationData, RegistrationRequest } from '../../src/registration/registration-types.js'; +import { RegistrationManager } from '../../src/registration/registration-manager.js'; +import { getDialectFromURI } from '../../src/storage.js'; +import { DwnServerErrorCode } from '../../src/dwn-error.js'; +import { ProofOfWorkManager } from '../../src/registration/proof-of-work-manager.js'; + +if (!globalThis.crypto) { + // @ts-ignore + globalThis.crypto = webcrypto; +} + +describe('Registration scenarios', function () { + const dwnMessageEndpoint = 'http://localhost:3000'; + const termsOfUseEndpoint = 'http://localhost:3000/register/terms-of-service'; + const proofOfWorkEndpoint = 'http://localhost:3000/register/proof-of-work'; + const registrationEndpoint = 'http://localhost:3000/registration'; + + let httpApi: HttpApi; + let server: Server; + let alice: Persona; + let tenantGate: RegisteredTenantGate; + let registrationManager: RegistrationManager; + let dwn: Dwn; + let clock; + + before(async function () { + clock = useFakeTimers({ shouldAdvanceTime: true }); + + config.registrationProofOfWorkEnabled = true; + config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; + const testDwn = await getTestDwn(true, true); + dwn = testDwn.dwn; + tenantGate = testDwn.tenantGate; + + const sqlDialect = getDialectFromURI(new URL('sqlite://')); + const termsOfService = readFileSync(config.termsOfServiceFilePath).toString(); + registrationManager = await RegistrationManager.create({ sqlDialect, termsOfService }); + + httpApi = new HttpApi(config, dwn, tenantGate, registrationManager); + + await tenantGate.initialize(); + alice = await DidKeyResolver.generate(); + await tenantGate.authorizeTenantTermsOfService(alice.did); + }); + + beforeEach(async function () { + server = await httpApi.start(3000); + }); + + afterEach(async function () { + server.close(); + server.closeAllConnections(); + }); + + after(function () { + clock.restore(); + }); + + it('should facilitate tenant registration with terms-of-service and proof-or-work turned on', async () => { + // Scenario: + // 1. Alice fetches the terms-of-service. + // 2. Alice fetches the proof-of-work challenge. + // 3. Alice creates registration data based on the hash of the terms-of-service and her DID. + // 4. Alice computes the proof-of-work response nonce based on the the proof-of-work challenge and the registration data. + // 5. Alice sends the registration request to the server and is now registered. + // 6. Alice can now write to the DWN. + // 7. Sanity test that another non-tenant is NOT authorized to write. + + // 1. Alice fetches the terms-of-service. + const termsOfServiceGetResponse = await fetch(termsOfUseEndpoint, { + method: 'GET', + }); + const termsOfServiceFetched = await termsOfServiceGetResponse.text(); + expect(termsOfServiceGetResponse.status).to.equal(200); + expect(termsOfServiceFetched).to.equal(readFileSync(config.termsOfServiceFilePath).toString()); + + // 2. Alice fetches the proof-of-work challenge. + const proofOfWorkChallengeGetResponse = await fetch(proofOfWorkEndpoint, { + method: 'GET', + }); + const { challengeNonce, maximumAllowedHashValue} = await proofOfWorkChallengeGetResponse.json() as ProofOfWorkChallengeModel; + expect(proofOfWorkChallengeGetResponse.status).to.equal(200); + expect(challengeNonce.length).to.equal(64); + expect(ProofOfWorkManager.isHexString(challengeNonce)).to.be.true; + expect(ProofOfWorkManager.isHexString(maximumAllowedHashValue)).to.be.true; + + // 3. Alice creates registration data based on the hash of the terms-of-service and her DID. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfServiceFetched]), + }; + + // 4. Alice computes the proof-of-work response nonce based on the the proof-of-work challenge and the registration data. + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + // 5. Alice sends the registration request to the server and is now registered. + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + expect(registrationResponse.status).to.equal(200); + + // 6. Alice can now write to the DWN. + const { jsonRpcRequest, dataBytes } = await createRecordsWriteJsonRpcRequest(alice); + const writeResponse = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(jsonRpcRequest), + }, + body: new Blob([dataBytes]), + }); + const writeResponseBody = await writeResponse.json() as JsonRpcResponse; + expect(writeResponse.status).to.equal(200); + expect(writeResponseBody.result.reply.status.code).to.equal(202); + + // 7. Sanity test that another non-tenant is NOT authorized to write. + const nonTenant = await DidKeyResolver.generate(); + const nonTenantJsonRpcRequest = await createRecordsWriteJsonRpcRequest(nonTenant); + const nonTenantJsonRpcResponse = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(nonTenantJsonRpcRequest.jsonRpcRequest), + }, + body: new Blob([nonTenantJsonRpcRequest.dataBytes]), + }); + const nonTenantJsonRpcResponseBody = await nonTenantJsonRpcResponse.json() as JsonRpcResponse; + expect(nonTenantJsonRpcResponse.status).to.equal(200); + expect(nonTenantJsonRpcResponseBody.result.reply.status.code).to.equal(401); + }); + + + it('should reject a registration request that has proof-or-work that does not meet the difficulty requirement.', async function () { + + // Scenario: + // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. + // 1. Alice computes the proof-of-work response nonce that is insufficient to meet the difficulty requirement. + // 2. Alice sends the registration request to the server and is rejected. + + // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. + const termsOfService = registrationManager.getTermsOfService(); + const { challengeNonce } = registrationManager.getProofOfWorkChallenge(); + + // Force the difficulty to be practically impossible. + const originalMaximumAllowedHashValueAsBigInt = registrationManager['proofOfWorkManager']['currentMaximumHashValueAsBigInt']; // for restoring later below + registrationManager['proofOfWorkManager']['currentMaximumHashValueAsBigInt'] = BigInt('0x0000000000000000000000000000000000000000000000000000000000000001'); + + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfService]), + }; + + // 1. Alice computes the proof-of-work response nonce that is insufficient to meet the difficulty requirement. + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', // any hash value will always be less or equal to this value + requestData: JSON.stringify(registrationData), + }); + + // Restoring original difficulty for subsequent tests. + registrationManager['proofOfWorkManager']['currentMaximumHashValueAsBigInt'] = originalMaximumAllowedHashValueAsBigInt; + + // 2. Alice sends the registration request to the server and is rejected. + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + const registrationResponseBody = await registrationResponse.json() as any; + expect(registrationResponse.status).to.equal(400); + expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.ProofOfWorkInsufficientSolutionNonce); + }); + + it('should reject a registration request that uses an invalid/outdated terms-of-service hash', async () => { + // Scenario: + // 0. Assume Alice fetched the proof-of-work challenge. + // 1. Alice constructs the registration data with an invalid/outdated terms-of-service hash. + // 2. Alice sends the registration request to the server and it is rejected. + + // 0. Assume Alice fetched the proof-of-work challenge. + const { challengeNonce, maximumAllowedHashValue } = registrationManager.getProofOfWorkChallenge(); + + // 1. Alice constructs the registration data with an invalid/outdated terms-of-service hash. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString(['invalid-or-outdated-terms-of-service']), + }; + + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + // 2. Alice sends the registration request to the server and it is rejected. + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + const registrationResponseBody = await registrationResponse.json() as any; + expect(registrationResponse.status).to.equal(400); + expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.RegistrationManagerInvalidOrOutdatedTermsOfServiceHash); + }); + + it('should reject an invalid nonce that is not a HEX string representing a 256 bit value.', async function () { + + // Assume Alice fetched the terms-of-service. + const termsOfService = registrationManager.getTermsOfService(); + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfService]), + }; + + const registrationRequest1: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce: 'unused', + responseNonce: 'not-a-hex-string', + }, + }; + + const registrationResponse1 = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest1), + }); + const registrationResponseBody1 = await registrationResponse1.json() as any; + expect(registrationResponse1.status).to.equal(400); + expect(registrationResponseBody1.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerInvalidResponseNonceFormat); + + const registrationRequest2: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce: 'unused', + responseNonce: 'FFFF', // HEX string too short + }, + }; + + const registrationResponse2 = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest2), + }); + const registrationResponseBody2 = await registrationResponse2.json() as any; + expect(registrationResponse2.status).to.equal(400); + expect(registrationResponseBody2.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerInvalidResponseNonceFormat); + }); + + it('should reject a registration request that uses an expired 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. + + // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. + const termsOfService = registrationManager.getTermsOfService(); + const { challengeNonce, maximumAllowedHashValue } = registrationManager.getProofOfWorkChallenge(); + + // 1. A long time has passed since Alice fetched the proof-of-work challenge and the challenge nonce has expired. + clock.tick(10 * 60 * 1000); // 10 minutes has passed + clock.runToLast(); // triggers all scheduled timers + + // 2. Alice computes the proof-of-work response nonce based on the the proof-of-work challenge and the registration data. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfService]), + }; + + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + // 3. Alice sends the registration request to the server and it is rejected. + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + const registrationResponseBody = await registrationResponse.json() as any; + expect(registrationResponse.status).to.equal(400); + expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerInvalidChallengeNonce); + }); +}); + +async function createRecordsWriteJsonRpcRequest(persona: Persona): Promise<{ jsonRpcRequest: JsonRpcRequest, dataBytes: Uint8Array }> { + const { recordsWrite, dataStream } = await createRecordsWriteMessage(persona); + + const requestId = uuidv4(); + const jsonRpcRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsWrite.toJSON(), + target: persona.did, + }); + + const dataBytes = await DataStream.toBytes(dataStream); + return { jsonRpcRequest, dataBytes }; +} From 0720f679db251aed7c16e5e72e31db056320b4cb Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Mon, 8 Jan 2024 21:52:41 -0800 Subject: [PATCH 11/27] Removed obsolete code in RegisteredTenantGate. --- .c8rc.json | 21 ++- src/dwn-server.ts | 12 +- src/http-api.ts | 41 ++--- src/registered-tenant-gate.ts | 152 ------------------- src/registration/registration-manager.ts | 35 ++++- src/registration/registration-store.ts | 17 ++- src/registration/registration-tenant-gate.ts | 25 +++ src/storage.ts | 4 +- tests/cors.spec.ts | 4 +- tests/dwn-process-message.spec.ts | 4 +- tests/dwn-server.spec.ts | 4 +- tests/http-api.spec.ts | 143 ++--------------- tests/process-handler.spec.ts | 2 +- tests/scenarios/registration.spec.ts | 14 +- tests/test-dwn.ts | 23 +-- tests/ws-api.spec.ts | 4 +- 16 files changed, 130 insertions(+), 375 deletions(-) delete mode 100644 src/registered-tenant-gate.ts create mode 100644 src/registration/registration-tenant-gate.ts diff --git a/.c8rc.json b/.c8rc.json index 4304477..620afa6 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -1,8 +1,19 @@ { "all": true, "cache": false, - "extension": [".js"], - "include": ["dist/esm/src/**"], - "exclude": ["dist/esm/src/types/**"], - "reporter": ["text", "cobertura", "html"] -} + "extension": [ + ".js" + ], + "include": [ + "dist/esm/src/**" + ], + "exclude": [ + "dist/esm/src/types/**", + "dist/esm/src/**/*-types.js" + ], + "reporter": [ + "text", + "cobertura", + "html" + ] +} \ No newline at end of file diff --git a/src/dwn-server.ts b/src/dwn-server.ts index 31f70c3..182d0e4 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -11,7 +11,6 @@ import { HttpServerShutdownHandler } from './lib/http-server-shutdown-handler.js import { type Config, config as defaultConfig } from './config.js'; import { HttpApi } from './http-api.js'; import { setProcessHandlers } from './process-handlers.js'; -import { RegisteredTenantGate } from './registered-tenant-gate.js'; import { getDWNConfig, getDialectFromURI } from './storage.js'; import { WsApi } from './ws-api.js'; import { RegistrationManager } from './registration/registration-manager.js'; @@ -59,21 +58,14 @@ export class DwnServer { new URL(this.config.tenantRegistrationStore), ); - let tenantGate: RegisteredTenantGate; let registrationManager: RegistrationManager; if (!this.dwn) { - - tenantGate = new RegisteredTenantGate( - tenantGateDB, - this.config.registrationProofOfWorkEnabled, - termsOfService, - ); registrationManager = await RegistrationManager.create({ sqlDialect: tenantGateDB, termsOfService }); - this.dwn = await Dwn.create(getDWNConfig(this.config, tenantGate)); + this.dwn = await Dwn.create(getDWNConfig(this.config, registrationManager.getTenantGate())); } - this.#httpApi = new HttpApi(this.config, this.dwn, tenantGate, registrationManager); + this.#httpApi = new HttpApi(this.config, this.dwn, registrationManager); await this.#httpApi.start(this.config.port, () => { log.info(`HttpServer listening on port ${this.config.port}`); diff --git a/src/http-api.ts b/src/http-api.ts index 31f5fa0..52d24ae 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -19,7 +19,6 @@ import { config } from './config.js'; import { type DwnServerError } from './dwn-error.js'; import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter, responseHistogram } from './metrics.js'; -import type { RegisteredTenantGate } from './registered-tenant-gate.js'; import type { RegistrationManager } from './registration/registration-manager.js'; const packageJson = process.env.npm_package_json ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) : {}; @@ -28,17 +27,18 @@ export class HttpApi { #config: Config; #api: Express; #server: http.Server; - tenantGate: RegisteredTenantGate; registrationManager: RegistrationManager; dwn: Dwn; - constructor(config: Config, dwn: Dwn, tenantGate: RegisteredTenantGate, registrationManager: RegistrationManager) { + constructor(config: Config, dwn: Dwn, registrationManager: RegistrationManager) { this.#config = config; this.#api = express(); this.#server = http.createServer(this.#api); this.dwn = dwn; - this.tenantGate = tenantGate; - this.registrationManager = registrationManager; + + if (registrationManager !== undefined) { + this.registrationManager = registrationManager; + } this.#setupMiddleware(); this.#setupRoutes(); @@ -196,35 +196,21 @@ export class HttpApi { #setupRegistrationRoutes(): void { if (this.#config.registrationProofOfWorkEnabled) { this.#api.get('/register/proof-of-work', async (_req: Request, res: Response) => { - const proofOfWorkChallenge = await this.registrationManager.getProofOfWorkChallenge(); + const proofOfWorkChallenge = this.registrationManager.getProofOfWorkChallenge(); res.json(proofOfWorkChallenge); }); } + if (this.#config.termsOfServiceFilePath !== undefined) { - this.#api.get('/register/terms-of-service', (_req: Request, res: Response) => res.send(this.tenantGate.termsOfService)); - this.#api.post('/register/terms-of-service', async (req: Request, res: Response) => { - try { - await this.tenantGate.handleTermsOfServicePost(req.body); - res.status(200).json({ success: true }); - } catch (error) { - const dwnServerError = error as DwnServerError; - - if (dwnServerError.code !== undefined) { - res.status(400).json({ - success : false, - reason : dwnServerError.message, - }); - } else { - console.log('Error handling terms-of-service POST:', error); - res.status(500).json({ success: false }); - } - } - }); + this.#api.get('/register/terms-of-service', (_req: Request, res: Response) => res.send(this.registrationManager.getTermsOfService())); } this.#api.post('/registration', async (req: Request, res: Response) => { + const requestBody = req.body; + console.log('Registration request:', requestBody); + try { - await this.registrationManager.handleRegistrationRequest(req.body); + await this.registrationManager.handleRegistrationRequest(requestBody); res.status(200).json({ success: true }); } catch (error) { const dwnServerError = error as DwnServerError; @@ -240,9 +226,6 @@ export class HttpApi { } async start(port: number, callback?: () => void): Promise { - if (this.tenantGate) { - await this.tenantGate.initialize(); - } this.#listen(port, callback); return this.#server; } diff --git a/src/registered-tenant-gate.ts b/src/registered-tenant-gate.ts deleted file mode 100644 index 9a5e5eb..0000000 --- a/src/registered-tenant-gate.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { TenantGate } from '@tbd54566975/dwn-sdk-js'; - -import type { Dialect } from 'kysely'; -import { Kysely } from 'kysely'; - -import { DwnServerError } from './dwn-error.js'; -import { DwnServerErrorCode } from './dwn-error.js'; -import { ProofOfWork } from './registration/proof-of-work.js'; - -const recentChallenges: { [challengeNonce: string]: number } = {}; -const CHALLENGE_TIMEOUT = 5 * 60 * 1000; // challenges are valid this long after issuance -const COMPLEXITY_LOOKBACK = 5 * 60 * 1000; // complexity is based on number of successful registrations in this time frame -const COMPLEXITY_MINIMUM = BigInt('0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'); - -export class RegisteredTenantGate implements TenantGate { - #db: Kysely; - #proofOfWorkRequired: boolean; - #termsOfServiceHash?: string; - #termsOfService?: string; - - get termsOfService(): string { - return this.#termsOfService; - } - - constructor(dialect: Dialect, proofOfWorkRequired: boolean, termsOfService?: string) { - this.#db = new Kysely({ dialect: dialect }); - this.#proofOfWorkRequired = proofOfWorkRequired; - - if (termsOfService) { - this.#termsOfServiceHash = ProofOfWork.hashAsHexString([termsOfService]); - this.#termsOfService = termsOfService; - } - } - - async initialize(): Promise { - setInterval(() => { - for (const challenge of Object.keys(recentChallenges)) { - if (recentChallenges[challenge] && Date.now() - recentChallenges[challenge] > CHALLENGE_TIMEOUT) { - delete recentChallenges[challenge]; - } - } - }, CHALLENGE_TIMEOUT / 4); - - await this.#db.schema - .createTable('authorizedTenants') - .ifNotExists() - .addColumn('did', 'text', (column) => column.primaryKey()) - .addColumn('proofOfWorkTime', 'timestamp') - .addColumn('termsOfServiceHash', 'boolean') - .execute(); - } - - async isActiveTenant(tenant: string): Promise { - if (!this.#proofOfWorkRequired && !this.#termsOfService) { - return true; - } - - const result = await this.#db - .selectFrom('authorizedTenants') - .select('proofOfWorkTime') - .select('termsOfServiceHash') - .where('did', '=', tenant) - .execute(); - - if (result.length === 0) { - console.log('rejecting tenant that is not in the database', { tenant }); - return false; - } - - const row = result[0]; - - if (this.#termsOfService && row.termsOfServiceHash != this.#termsOfServiceHash) { - console.log('rejecting tenant that has not accepted the current terms of service', { row, tenant, expected: this.#termsOfServiceHash }); - return false; - } - - return true; - } - - private async getComplexity(): Promise { - const result = await this.#db - .selectFrom('authorizedTenants') - .where('proofOfWorkTime', '>', Date.now() - COMPLEXITY_LOOKBACK) - .select((eb) => eb.fn.countAll().as('recent_reg_count')) - .executeTakeFirstOrThrow(); - const recentRegistrationCount = result.recent_reg_count as number; - if (recentRegistrationCount == 0) { - return COMPLEXITY_MINIMUM; - } - - const complexity = COMPLEXITY_MINIMUM / BigInt(recentRegistrationCount); - if (complexity < COMPLEXITY_MINIMUM) { - return COMPLEXITY_MINIMUM; - } - - return complexity; - } - - async handleTermsOfServicePost(body: { - did: string; - termsOfServiceHash: string; - }): Promise { - - if (body.termsOfServiceHash != this.#termsOfServiceHash) { - throw new DwnServerError(DwnServerErrorCode.TenantRegistrationOutdatedTermsOfService, `Outdated terms of service.`); - } - - console.log('accepting terms of service', body); - - await this.#db - .insertInto('authorizedTenants') - .values({ - did: body.did, - termsOfServiceHash: body.termsOfServiceHash, - }) - // If a row with the same `did` already exists, it updates the `termsOfServiceHash` of the existing row - // to the `termsOfServiceHash` of the row that was attempted to be inserted (`excluded.termsOfServiceHash`). - .onConflict((onConflictBuilder) => - onConflictBuilder.column('did').doUpdateSet((expressionBuilder) => ({ - termsOfServiceHash: expressionBuilder.ref('excluded.termsOfServiceHash'), - })), - ) - // Executes the query. If the query doesn’t affect any rows (ie. if the insert or update didn’t change anything), it throws an error. - .executeTakeFirstOrThrow(); - } - - async authorizeTenantTermsOfService(tenant: string): Promise { - await this.#db - .insertInto('authorizedTenants') - .values({ - did: tenant, - termsOfServiceHash: this.#termsOfServiceHash, - }) - .onConflict((onConflictBuilder) => - onConflictBuilder.column('did').doUpdateSet((expressionBuilder) => ({ - termsOfServiceHash: expressionBuilder.ref('excluded.termsOfServiceHash'), - })), - ) - // Executes the query. No error is thrown if the query doesn’t affect any rows (ie. if the insert or update didn’t change anything). - .executeTakeFirst(); - } -} - -interface AuthorizedTenants { - did: string; - termsOfServiceHash: string; - proofOfWorkTime: number; -} - -interface TenantRegistrationDatabase { - authorizedTenants: AuthorizedTenants; -} diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts index 02e9269..2b23a8e 100644 --- a/src/registration/registration-manager.ts +++ b/src/registration/registration-manager.ts @@ -2,21 +2,32 @@ import type { Dialect } from "@tbd54566975/dwn-sql-store"; import { ProofOfWorkManager } from "./proof-of-work-manager.js"; import { ProofOfWork } from "./proof-of-work.js"; import { RegistrationStore } from "./registration-store.js"; -import type { RegistrationRequest } from "./registration-types.js"; +import type { RegistrationData, RegistrationRequest } from "./registration-types.js"; import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; +import type { TenantGate } from "@tbd54566975/dwn-sdk-js"; +import { RegistrationTenantGate } from "./registration-tenant-gate.js"; export class RegistrationManager { + private tenantGate: TenantGate; private proofOfWorkManager: ProofOfWorkManager; private registrationStore: RegistrationStore; private termsOfServiceHash?: string; private termsOfService?: string; + public getTenantGate(): TenantGate { + return this.tenantGate; + } + public getTermsOfService(): string { return this.termsOfService; } + public getTermsOfServiceHash(): string { + return this.termsOfServiceHash; + } + private constructor (termsOfService?: string) { if (termsOfService) { this.termsOfServiceHash = ProofOfWork.hashAsHexString([termsOfService]); @@ -31,17 +42,19 @@ export class RegistrationManager { const { termsOfService, sqlDialect } = input; // Initialize and start ProofOfWorkManager. - const proofOfWorkManager = new RegistrationManager(termsOfService); - proofOfWorkManager.proofOfWorkManager = await ProofOfWorkManager.create({ + const registrationManager = new RegistrationManager(termsOfService); + registrationManager.proofOfWorkManager = await ProofOfWorkManager.create({ autoStart: true, desiredSolveCountPerMinute: 10, initialMaximumHashValue: '00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' }); // Initialize RegistrationStore. - proofOfWorkManager.registrationStore = await RegistrationStore.create(sqlDialect); - - return proofOfWorkManager; + const registrationStore = await RegistrationStore.create(sqlDialect); + registrationManager.registrationStore = registrationStore; + registrationManager.tenantGate = await RegistrationTenantGate.create(registrationStore, registrationManager.getTermsOfServiceHash()); + + return registrationManager; } public getProofOfWorkChallenge(): ProofOfWorkChallengeModel { @@ -67,6 +80,14 @@ export class RegistrationManager { }); // Store tenant registration data in database. - await this.registrationStore.insertOrUpdateTenantRegistration(registrationRequest.registrationData); + await this.recordTenantRegistration(registrationRequest.registrationData); + } + + /** + * Records the given registration data in the database. + * Exposed as a public method for testing purposes. + */ + public async recordTenantRegistration(registrationData: RegistrationData): Promise { + await this.registrationStore.insertOrUpdateTenantRegistration(registrationData); } } diff --git a/src/registration/registration-store.ts b/src/registration/registration-store.ts index 60e7ffb..18b0fce 100644 --- a/src/registration/registration-store.ts +++ b/src/registration/registration-store.ts @@ -35,14 +35,29 @@ export class RegistrationStore { termsOfServiceHash: eb.ref('excluded.termsOfServiceHash'), })), ) + // Executes the query. No error is thrown if the query doesn’t affect any rows (ie. if the insert or update didn’t change anything). .executeTakeFirst(); } + + public async getTenantRegistration(tenantDid: string): Promise { + const result = await this.db + .selectFrom('authorizedTenants') + .select('did') + .select('termsOfServiceHash') + .where('did', '=', tenantDid) + .execute(); + + if (result.length === 0) { + return undefined; + } + + return result[0]; + } } interface AuthorizedTenants { did: string; termsOfServiceHash: string; - proofOfWorkTime: number; } interface RegistrationDatabase { diff --git a/src/registration/registration-tenant-gate.ts b/src/registration/registration-tenant-gate.ts new file mode 100644 index 0000000..df3430e --- /dev/null +++ b/src/registration/registration-tenant-gate.ts @@ -0,0 +1,25 @@ +import type { TenantGate } from '@tbd54566975/dwn-sdk-js'; +import type { RegistrationStore } from './registration-store.js'; + +export class RegistrationTenantGate implements TenantGate { + private constructor(private registrationStore: RegistrationStore, private termsOfServiceHash: string) { } + + public static async create(registrationStore: RegistrationStore, termsOfServiceHash: string): Promise { + const tenantGate = new RegistrationTenantGate(registrationStore, termsOfServiceHash); + return tenantGate; + } + + public async isActiveTenant(tenant: string): Promise { + const tenantRegistration = await this.registrationStore.getTenantRegistration(tenant); + + if (tenantRegistration === undefined) { + return false + } + + if (tenantRegistration.termsOfServiceHash !== this.termsOfServiceHash) { + return false; + } + + return true; + } +} diff --git a/src/storage.ts b/src/storage.ts index 486b2d7..2133c7a 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -8,6 +8,7 @@ import type { DwnConfig, EventLog, MessageStore, + TenantGate, } from '@tbd54566975/dwn-sdk-js'; import type { Dialect } from '@tbd54566975/dwn-sql-store'; import { @@ -25,7 +26,6 @@ import pg from 'pg'; import Cursor from 'pg-cursor'; import type { Config } from './config.js'; -import type { RegisteredTenantGate } from './registered-tenant-gate.js'; export enum EStoreType { DataStore, @@ -44,7 +44,7 @@ export type StoreType = DataStore | EventLog | MessageStore; export function getDWNConfig( config: Config, - tenantGate: RegisteredTenantGate, + tenantGate: TenantGate, ): DwnConfig { const dataStore: DataStore = getStore(config.dataStore, EStoreType.DataStore); const eventLog: EventLog = getStore(config.eventLog, EStoreType.EventLog); diff --git a/tests/cors.spec.ts b/tests/cors.spec.ts index 88a2719..69f6d54 100644 --- a/tests/cors.spec.ts +++ b/tests/cors.spec.ts @@ -24,9 +24,9 @@ class CorsProxySetup { proxyPort = 9875; public async start(): Promise { - const testdwn = await getTestDwn(); + const testDwn = await getTestDwn(); const dwnServer = new DwnServer({ - dwn: testdwn.dwn, + dwn: testDwn, config: { ...defaultConfig, port: 0, // UNSPEC to obtain test specific free port diff --git a/tests/dwn-process-message.spec.ts b/tests/dwn-process-message.spec.ts index a5e137f..450e43e 100644 --- a/tests/dwn-process-message.spec.ts +++ b/tests/dwn-process-message.spec.ts @@ -21,7 +21,7 @@ describe('handleDwnProcessMessage', function () { target: alice.did, }); - const dwn = (await getTestDwn()).dwn; + const dwn = await getTestDwn(); const context: RequestContext = { dwn, transport: 'http', dataStream }; const { jsonRpcResponse } = await handleDwnProcessMessage( @@ -46,7 +46,7 @@ describe('handleDwnProcessMessage', function () { target: 'did:key:abc1234', }); - const dwn = (await getTestDwn()).dwn; + const dwn = await getTestDwn(); const context: RequestContext = { dwn, transport: 'http' }; const { jsonRpcResponse } = await handleDwnProcessMessage( diff --git a/tests/dwn-server.spec.ts b/tests/dwn-server.spec.ts index 8dc94e1..b3e5e7a 100644 --- a/tests/dwn-server.spec.ts +++ b/tests/dwn-server.spec.ts @@ -7,8 +7,8 @@ import { getTestDwn } from './test-dwn.js'; describe('DwnServer', function () { let dwnServer: DwnServer; before(async function () { - const testdwn = await getTestDwn(); - dwnServer = new DwnServer({ dwn: testdwn.dwn, config: config }); + const testDwn = await getTestDwn(); + dwnServer = new DwnServer({ dwn: testDwn, config: config }); }); after(async function () { dwnServer.stop(() => console.log('server stop')); diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index a16f830..02b7767 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -28,8 +28,6 @@ import { createJsonRpcRequest, JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; -import type { RegisteredTenantGate } from '../src/registered-tenant-gate.js'; -import { ProofOfWork } from '../src/registration/proof-of-work.js'; import { getTestDwn } from './test-dwn.js'; import type { Profile } from './utils.js'; import { @@ -49,7 +47,6 @@ describe('http api', function () { let httpApi: HttpApi; let server: Server; let profile: Profile; - let tenantGate: RegisteredTenantGate; let registrationManager: RegistrationManager; let dwn: Dwn; let clock; @@ -59,19 +56,18 @@ describe('http api', function () { config.registrationProofOfWorkEnabled = true; config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; - const testDwn = await getTestDwn(true, true); - dwn = testDwn.dwn; - tenantGate = testDwn.tenantGate; - + + // RegistrationManager creation const sqlDialect = getDialectFromURI(new URL('sqlite://')); const termsOfService = readFileSync(config.termsOfServiceFilePath).toString(); registrationManager = await RegistrationManager.create({ sqlDialect, termsOfService }); - httpApi = new HttpApi(config, dwn, tenantGate, registrationManager); + dwn = await getTestDwn(registrationManager.getTenantGate()); + + httpApi = new HttpApi(config, dwn, registrationManager); - await tenantGate.initialize(); profile = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantTermsOfService(profile.did); + await registrationManager.recordTenantRegistration({ did: profile.did, termsOfServiceHash: registrationManager.getTermsOfServiceHash()}); }); beforeEach(async function () { @@ -87,122 +83,6 @@ describe('http api', function () { clock.restore(); }); - describe('/register/terms-of-service', function () { - it('allow tenant that after accepting the terms of service', async function () { - const response = await fetch( - 'http://localhost:3000/register/terms-of-service', - ); - expect(response.status).to.equal(200); - - const terms = await response.text(); - - expect(terms).to.equal( - readFileSync('./tests/fixtures/terms-of-service.txt').toString(), - ); - - const termsOfServiceHash = ProofOfWork.hashAsHexString([terms]); - - const p = await DidKeyResolver.generate(); - - const acceptResponse = await fetch( - 'http://localhost:3000/register/terms-of-service', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - did: p.did, - termsOfServiceHash - }), - }, - ); - expect(acceptResponse.status).to.equal(200); - - const recordsQuery = await RecordsQuery.create({ - filter: { schema: 'woosa' }, - signer: p.signer, - }); - - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: p.did, - }); - - const rpcResponse = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); - - console.log(rpcResponse.body.result.reply.status); - expect(rpcResponse.statusCode).to.equal(200); - expect(rpcResponse.body.id).to.equal(requestId); - expect(rpcResponse.body.result.reply.status.code).to.equal(200); - }); - - it('rejects tenants that have completed proof-of-work but have not accepted the terms of use', async function () { - const unauthorized = await DidKeyResolver.generate(); - const recordsQuery = await RecordsQuery.create({ - filter: { schema: 'woosa' }, - signer: unauthorized.signer, - }); - - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: unauthorized.did, - }); - - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); - - expect(response.statusCode).to.equal(200); - expect(response.body.id).to.equal(requestId); - expect(response.body.result.reply.status.code).to.equal(401); - }); - - it('rejects terms of use acceptance with incorrect hash', async function () { - const termsOfServiceHash = ProofOfWork.hashAsHexString(['i do not agree']); // incorrect hash - - const p = await DidKeyResolver.generate(); - - const acceptResponse = await fetch( - 'http://localhost:3000/register/terms-of-service', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - did: p.did, - termsOfServiceHash - }), - }, - ); - expect(acceptResponse.status).to.equal(400); - - const recordsQuery = await RecordsQuery.create({ - filter: { schema: 'woosa' }, - signer: p.signer, - }); - - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: p.did, - }); - - const rpcResponse = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); - - console.log(rpcResponse.body.result.reply.status); - expect(rpcResponse.statusCode).to.equal(200); - expect(rpcResponse.body.id).to.equal(requestId); - expect(rpcResponse.body.result.reply.status.code).to.equal(401); - }); - }); - describe('/ (rpc)', function () { it('responds with a 400 if no dwn-request header is provided', async function () { const response = await request(httpApi.api).post('/').send(); @@ -352,17 +232,14 @@ describe('http api', function () { }); it('handles RecordsWrite overwrite that does not mutate data', async function () { - const p = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantTermsOfService(p.did); - // First RecordsWrite that creates the record. const { recordsWrite: initialWrite, dataStream } = - await createRecordsWriteMessage(p); + await createRecordsWriteMessage(profile); const dataBytes = await DataStream.toBytes(dataStream); let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: initialWrite.toJSON(), - target: p.did, + target: profile.did, }); const responseInitialWrite = await fetch('http://localhost:3000', { @@ -379,7 +256,7 @@ describe('http api', function () { await Time.minimalSleep(); // Subsequent RecordsWrite that mutates the published property of the record. - const { recordsWrite: overWrite } = await createRecordsWriteMessage(p, { + const { recordsWrite: overWrite } = await createRecordsWriteMessage(profile, { recordId: initialWrite.message.recordId, dataCid: initialWrite.message.descriptor.dataCid, dataSize: initialWrite.message.descriptor.dataSize, @@ -390,7 +267,7 @@ describe('http api', function () { requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: overWrite.toJSON(), - target: p.did, + target: profile.did, }); const responseOverwrite = await fetch('http://localhost:3000', { method: 'POST', diff --git a/tests/process-handler.spec.ts b/tests/process-handler.spec.ts index ad96e07..4110946 100644 --- a/tests/process-handler.spec.ts +++ b/tests/process-handler.spec.ts @@ -11,7 +11,7 @@ describe('Process Handlers', function () { beforeEach(async function () { const testdwn = await getTestDwn(); - dwnServer = new DwnServer({ dwn: testdwn.dwn, config: config }); + dwnServer = new DwnServer({ dwn: testdwn, config: config }); await dwnServer.start(); processExitStub = sinon.stub(process, 'exit'); }); diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts index a922655..e4bf6b7 100644 --- a/tests/scenarios/registration.spec.ts +++ b/tests/scenarios/registration.spec.ts @@ -22,7 +22,6 @@ import type { import { createJsonRpcRequest, } from '../../src/lib/json-rpc.js'; -import type { RegisteredTenantGate } from '../../src/registered-tenant-gate.js'; import { ProofOfWork } from '../../src/registration/proof-of-work.js'; import { getTestDwn } from '../test-dwn.js'; import { @@ -49,7 +48,6 @@ describe('Registration scenarios', function () { let httpApi: HttpApi; let server: Server; let alice: Persona; - let tenantGate: RegisteredTenantGate; let registrationManager: RegistrationManager; let dwn: Dwn; let clock; @@ -59,19 +57,17 @@ describe('Registration scenarios', function () { config.registrationProofOfWorkEnabled = true; config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; - const testDwn = await getTestDwn(true, true); - dwn = testDwn.dwn; - tenantGate = testDwn.tenantGate; + // RegistrationManager creation const sqlDialect = getDialectFromURI(new URL('sqlite://')); const termsOfService = readFileSync(config.termsOfServiceFilePath).toString(); registrationManager = await RegistrationManager.create({ sqlDialect, termsOfService }); - httpApi = new HttpApi(config, dwn, tenantGate, registrationManager); + dwn = await getTestDwn(registrationManager.getTenantGate()); + + httpApi = new HttpApi(config, dwn, registrationManager); - await tenantGate.initialize(); alice = await DidKeyResolver.generate(); - await tenantGate.authorizeTenantTermsOfService(alice.did); }); beforeEach(async function () { @@ -136,7 +132,7 @@ describe('Registration scenarios', function () { responseNonce, }, }; - + const registrationResponse = await fetch(registrationEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/tests/test-dwn.ts b/tests/test-dwn.ts index 6b51298..27c7a10 100644 --- a/tests/test-dwn.ts +++ b/tests/test-dwn.ts @@ -1,3 +1,4 @@ +import type { TenantGate } from '@tbd54566975/dwn-sdk-js'; import { Dwn } from '@tbd54566975/dwn-sdk-js'; import { DataStoreSql, @@ -5,29 +6,15 @@ import { MessageStoreSql, } from '@tbd54566975/dwn-sql-store'; -import { readFileSync } from 'node:fs'; - -import { RegisteredTenantGate } from '../src/registered-tenant-gate.js'; import { getDialectFromURI } from '../src/storage.js'; export async function getTestDwn( - proofOfWorkRequired?: boolean, - termsOfServiceRequired?: boolean, -): Promise<{ - dwn: Dwn; - tenantGate: RegisteredTenantGate; -}> { + tenantGate?: TenantGate +): Promise { const db = getDialectFromURI(new URL('sqlite://')); const dataStore = new DataStoreSql(db); const eventLog = new EventLogSql(db); const messageStore = new MessageStoreSql(db); - const tenantGate = new RegisteredTenantGate( - db, - proofOfWorkRequired, - termsOfServiceRequired - ? readFileSync('./tests/fixtures/terms-of-service.txt').toString() - : undefined, - ); let dwn: Dwn; try { @@ -35,11 +22,11 @@ export async function getTestDwn( eventLog, dataStore, messageStore, - tenantGate, + tenantGate }); } catch (e) { throw e; } - return { dwn, tenantGate }; + return dwn; } diff --git a/tests/ws-api.spec.ts b/tests/ws-api.spec.ts index 54b2936..c205a34 100644 --- a/tests/ws-api.spec.ts +++ b/tests/ws-api.spec.ts @@ -22,8 +22,8 @@ describe('websocket api', function () { server = http.createServer(); server.listen(9002, '127.0.0.1'); - const testdwn = await getTestDwn(); - const wsApi = new WsApi(server, testdwn.dwn); + const testDwn = await getTestDwn(); + const wsApi = new WsApi(server, testDwn); wsServer = wsApi.start(); }); From e525a5a0d9bb6854c9182c26aaa0b1358dcd6961 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Tue, 9 Jan 2024 14:46:27 -0800 Subject: [PATCH 12/27] 100% code coverage + removed dedicated RegistrationTenantGate in favor of simplicity --- src/dwn-server.ts | 2 +- src/registration/proof-of-work-manager.ts | 65 ++++++--- src/registration/proof-of-work.ts | 11 +- src/registration/registration-manager.ts | 34 +++-- src/registration/registration-tenant-gate.ts | 25 ---- tests/dwn-server.spec.ts | 6 + tests/http-api.spec.ts | 4 +- .../proof-of-work-manager.spec.ts | 138 +++++++++++++----- tests/scenarios/registration.spec.ts | 54 ++++++- 9 files changed, 230 insertions(+), 109 deletions(-) delete mode 100644 src/registration/registration-tenant-gate.ts diff --git a/src/dwn-server.ts b/src/dwn-server.ts index 182d0e4..aba6b8f 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -62,7 +62,7 @@ export class DwnServer { if (!this.dwn) { registrationManager = await RegistrationManager.create({ sqlDialect: tenantGateDB, termsOfService }); - this.dwn = await Dwn.create(getDWNConfig(this.config, registrationManager.getTenantGate())); + this.dwn = await Dwn.create(getDWNConfig(this.config, registrationManager)); } this.#httpApi = new HttpApi(this.config, this.dwn, registrationManager); diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index a6e4155..a5ffe8b 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -9,8 +9,8 @@ export class ProofOfWorkManager { private initialMaximumHashValueAsBigInt: bigint; private desiredSolveCountPerMinute: number; - static readonly challengeRefreshFrequencyInMilliseconds = 10 * 60 * 1000; // 10 minutes - static readonly difficultyReevaluationFrequencyInMilliseconds = 10000; + public challengeRefreshFrequencyInSeconds: number; + public difficultyReevaluationFrequencyInSeconds: number; public get currentMaximumAllowedHashValue(): bigint { return this.currentMaximumHashValueAsBigInt; @@ -20,19 +20,40 @@ export class ProofOfWorkManager { return this.proofOfWorkOfLastMinute.size; } - private constructor (desiredSolveCountPerMinute: number, initialMaximumHashValue: string) { + private constructor (input: { + desiredSolveCountPerMinute: number, + initialMaximumHashValue: string, + challengeRefreshFrequencyInSeconds: number, + difficultyReevaluationFrequencyInSeconds: number + }) { + const { desiredSolveCountPerMinute, initialMaximumHashValue } = input; + this.challengeNonces = { currentChallengeNonce: ProofOfWork.generateNonce() }; this.currentMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); this.initialMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); this.desiredSolveCountPerMinute = desiredSolveCountPerMinute; + this.challengeRefreshFrequencyInSeconds = input.challengeRefreshFrequencyInSeconds; + this.difficultyReevaluationFrequencyInSeconds = input.difficultyReevaluationFrequencyInSeconds; } public static async create(input: { desiredSolveCountPerMinute: number, initialMaximumHashValue: string, autoStart: boolean, + challengeRefreshFrequencyInSeconds?: number, + difficultyReevaluationFrequencyInSeconds?: number }): Promise { - const proofOfWorkManager = new ProofOfWorkManager(input.desiredSolveCountPerMinute, input.initialMaximumHashValue); + const { desiredSolveCountPerMinute, initialMaximumHashValue } = input; + + const challengeRefreshFrequencyInSeconds = input.challengeRefreshFrequencyInSeconds ?? 10 * 60; // 10 minutes default + const difficultyReevaluationFrequencyInSeconds = input.difficultyReevaluationFrequencyInSeconds ?? 10; // 10 seconds default + + const proofOfWorkManager = new ProofOfWorkManager({ + desiredSolveCountPerMinute, + initialMaximumHashValue, + challengeRefreshFrequencyInSeconds, + difficultyReevaluationFrequencyInSeconds + }); if (input.autoStart) { proofOfWorkManager.start(); @@ -104,12 +125,11 @@ export class ProofOfWorkManager { private periodicallyRefreshChallengeNonce (): void { try { - this.challengeNonces.previousChallengeNonce = this.challengeNonces.currentChallengeNonce; - this.challengeNonces.currentChallengeNonce = ProofOfWork.generateNonce(); + this.refreshChallengeNonce(); } catch (error) { console.error(`Encountered error while refreshing challenge nonce: ${error}`); } finally { - setTimeout(async () => this.periodicallyRefreshChallengeNonce(), ProofOfWorkManager.challengeRefreshFrequencyInMilliseconds); + setTimeout(async () => this.periodicallyRefreshChallengeNonce(), this.challengeRefreshFrequencyInSeconds * 1000); } } @@ -119,7 +139,7 @@ export class ProofOfWorkManager { } catch (error) { console.error(`Encountered error while updating proof of work difficulty: ${error}`); } finally { - setTimeout(async () => this.periodicallyRefreshProofOfWorkDifficulty(), ProofOfWorkManager.difficultyReevaluationFrequencyInMilliseconds); + setTimeout(async () => this.periodicallyRefreshProofOfWorkDifficulty(), this.difficultyReevaluationFrequencyInSeconds * 1000); } } @@ -132,6 +152,11 @@ export class ProofOfWorkManager { } } + private refreshChallengeNonce(): void { + this.challengeNonces.previousChallengeNonce = this.challengeNonces.currentChallengeNonce; + this.challengeNonces.currentChallengeNonce = ProofOfWork.generateNonce(); + } + /** * Refreshes the difficulty by changing the max hash value. * The higher the number, the easier. Scale 1 (hardest) to 2^256 (easiest), represented in HEX. @@ -149,7 +174,7 @@ export class ProofOfWorkManager { // NOTE: bigint arithmetic does NOT work with decimals, so we work with "full numbers" by multiplying by a scale factor. const scaleFactor = 1_000_000; - const difficultyEvaluationsPerMinute = 60000 / ProofOfWorkManager.difficultyReevaluationFrequencyInMilliseconds; // assumed to be >= 1; + const difficultyEvaluationsPerMinute = 60000 / (this.difficultyReevaluationFrequencyInSeconds * 1000); // assumed to be >= 1; // NOTE: easier difficulty is represented by a larger max allowed hash value // and harder difficulty is represented by a smaller max allowed hash value. @@ -173,22 +198,14 @@ export class ProofOfWorkManager { = (this.currentMaximumHashValueAsBigInt - newMaximumHashValueAsBigIntPriorToMultiplierAdjustment) * (BigInt(Math.floor(increaseMultiplier * scaleFactor)) / BigInt(scaleFactor)); - const hashValueDecreaseAmount - = hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment / BigInt(difficultyEvaluationsPerMinute); - - let newMaximumHashValueAsBigInt = this.currentMaximumHashValueAsBigInt - hashValueDecreaseAmount; + const hashValueDecreaseAmount = hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment / BigInt(difficultyEvaluationsPerMinute); - if (newMaximumHashValueAsBigInt === BigInt(0)) { - // if newMaximumHashValueAsBigInt is 0, we use 1 instead because 0 cannot multiply another number - newMaximumHashValueAsBigInt = BigInt(1); - } - - this.currentMaximumHashValueAsBigInt = newMaximumHashValueAsBigInt; + this.currentMaximumHashValueAsBigInt -= hashValueDecreaseAmount; } else { // if solve rate is lower than desired, make difficulty easier by making the max allowed hash value larger if (this.currentMaximumHashValueAsBigInt === this.initialMaximumHashValueAsBigInt) { - // if current difficulty is already at initial difficulty, don't make it any easier + // if current difficulty is already at initial difficulty, nothing to do return; } @@ -199,7 +216,13 @@ export class ProofOfWorkManager { = differenceBetweenInitialAndCurrentDifficulty / BigInt(backToInitialDifficultyInMinutes * difficultyEvaluationsPerMinute); } - this.currentMaximumHashValueAsBigInt += this.hashValueIncrementPerEvaluation; + // if newly calculated difficulty is lower than initial difficulty, just use the initial difficulty + const newMaximumAllowedHashValueAsBigInt = this.currentMaximumHashValueAsBigInt + this.hashValueIncrementPerEvaluation; + if (newMaximumAllowedHashValueAsBigInt >= this.initialMaximumHashValueAsBigInt) { + this.currentMaximumHashValueAsBigInt = this.initialMaximumHashValueAsBigInt; + } else { + this.currentMaximumHashValueAsBigInt = newMaximumAllowedHashValueAsBigInt; + } } } } diff --git a/src/registration/proof-of-work.ts b/src/registration/proof-of-work.ts index f7a0464..918ecc1 100644 --- a/src/registration/proof-of-work.ts +++ b/src/registration/proof-of-work.ts @@ -68,18 +68,9 @@ export class ProofOfWork { qualifiedSolutionNonceFound = computedHashAsBigInt <= maximumAllowedHashValueAsBigInt; iterations++; - - // Log every 1M iterations. - if (iterations % 1_000_000 === 0) { - console.log( - `iterations: ${iterations}, time lapsed: ${ - Date.now() - startTime - } ms`, - ); - } } while (!qualifiedSolutionNonceFound); - // Log final/successful attempt. + // Log final/successful iteration. console.log( `iterations: ${iterations}, time lapsed: ${Date.now() - startTime} ms`, ); diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts index 2b23a8e..0eae158 100644 --- a/src/registration/registration-manager.ts +++ b/src/registration/registration-manager.ts @@ -6,20 +6,14 @@ import type { RegistrationData, RegistrationRequest } from "./registration-types import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; import type { TenantGate } from "@tbd54566975/dwn-sdk-js"; -import { RegistrationTenantGate } from "./registration-tenant-gate.js"; -export class RegistrationManager { - private tenantGate: TenantGate; +export class RegistrationManager implements TenantGate { private proofOfWorkManager: ProofOfWorkManager; private registrationStore: RegistrationStore; private termsOfServiceHash?: string; private termsOfService?: string; - public getTenantGate(): TenantGate { - return this.tenantGate; - } - public getTermsOfService(): string { return this.termsOfService; } @@ -28,10 +22,17 @@ export class RegistrationManager { return this.termsOfServiceHash; } + /** + * Updates the terms-of-service. Exposed for testing purposes. + */ + public updateTermsOfService(termsOfService: string): void { + this.termsOfServiceHash = ProofOfWork.hashAsHexString([termsOfService]); + this.termsOfService = termsOfService; + } + private constructor (termsOfService?: string) { if (termsOfService) { - this.termsOfServiceHash = ProofOfWork.hashAsHexString([termsOfService]); - this.termsOfService = termsOfService; + this.updateTermsOfService(termsOfService); } } @@ -52,7 +53,6 @@ export class RegistrationManager { // Initialize RegistrationStore. const registrationStore = await RegistrationStore.create(sqlDialect); registrationManager.registrationStore = registrationStore; - registrationManager.tenantGate = await RegistrationTenantGate.create(registrationStore, registrationManager.getTermsOfServiceHash()); return registrationManager; } @@ -90,4 +90,18 @@ export class RegistrationManager { public async recordTenantRegistration(registrationData: RegistrationData): Promise { await this.registrationStore.insertOrUpdateTenantRegistration(registrationData); } + + public async isActiveTenant(tenant: string): Promise { + const tenantRegistration = await this.registrationStore.getTenantRegistration(tenant); + + if (tenantRegistration === undefined) { + return false + } + + if (tenantRegistration.termsOfServiceHash !== this.termsOfServiceHash) { + return false; + } + + return true; + } } diff --git a/src/registration/registration-tenant-gate.ts b/src/registration/registration-tenant-gate.ts deleted file mode 100644 index df3430e..0000000 --- a/src/registration/registration-tenant-gate.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { TenantGate } from '@tbd54566975/dwn-sdk-js'; -import type { RegistrationStore } from './registration-store.js'; - -export class RegistrationTenantGate implements TenantGate { - private constructor(private registrationStore: RegistrationStore, private termsOfServiceHash: string) { } - - public static async create(registrationStore: RegistrationStore, termsOfServiceHash: string): Promise { - const tenantGate = new RegistrationTenantGate(registrationStore, termsOfServiceHash); - return tenantGate; - } - - public async isActiveTenant(tenant: string): Promise { - const tenantRegistration = await this.registrationStore.getTenantRegistration(tenant); - - if (tenantRegistration === undefined) { - return false - } - - if (tenantRegistration.termsOfServiceHash !== this.termsOfServiceHash) { - return false; - } - - return true; - } -} diff --git a/tests/dwn-server.spec.ts b/tests/dwn-server.spec.ts index b3e5e7a..db3c501 100644 --- a/tests/dwn-server.spec.ts +++ b/tests/dwn-server.spec.ts @@ -7,6 +7,12 @@ import { getTestDwn } from './test-dwn.js'; describe('DwnServer', function () { let dwnServer: DwnServer; before(async function () { + + // Mute all server console logs during tests. + console.log = (): void => {}; + console.error = (): void => {}; + console.info = (): void => {}; + const testDwn = await getTestDwn(); dwnServer = new DwnServer({ dwn: testDwn, config: config }); }); diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 02b7767..66547aa 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -56,13 +56,13 @@ describe('http api', function () { config.registrationProofOfWorkEnabled = true; config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; - + // RegistrationManager creation const sqlDialect = getDialectFromURI(new URL('sqlite://')); const termsOfService = readFileSync(config.termsOfServiceFilePath).toString(); registrationManager = await RegistrationManager.create({ sqlDialect, termsOfService }); - dwn = await getTestDwn(registrationManager.getTenantGate()); + dwn = await getTestDwn(registrationManager); httpApi = new HttpApi(config, dwn, registrationManager); diff --git a/tests/registration/proof-of-work-manager.spec.ts b/tests/registration/proof-of-work-manager.spec.ts index e4d5954..081c04f 100644 --- a/tests/registration/proof-of-work-manager.spec.ts +++ b/tests/registration/proof-of-work-manager.spec.ts @@ -1,11 +1,12 @@ +import sinon from 'sinon'; + import { expect } from 'chai'; import { useFakeTimers } from 'sinon'; import { v4 as uuidv4 } from 'uuid'; import { ProofOfWorkManager } from '../..//src/registration/proof-of-work-manager.js'; - describe('ProofOfWorkManager', function () { let clock; @@ -23,46 +24,115 @@ describe('ProofOfWorkManager', function () { clock.restore(); }); - describe('complexity', function () { - - it('should become more complex as more successful proof-of-work is submitted', async function () { - const desiredSolveCountPerMinute = 10; - const initialMaximumHashValue = 'FFFFFFFF'; - const proofOfWorkManager = await ProofOfWorkManager.create({ - autoStart: false, - desiredSolveCountPerMinute, - initialMaximumHashValue, - }); + it('should periodically refresh the challenge nonce and proof-of-work difficulty', async function () { + const desiredSolveCountPerMinute = 10; + const initialMaximumHashValue = 'FFFFFFFF'; + const proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: true, + desiredSolveCountPerMinute, + initialMaximumHashValue, + }); - // Load up desiredSolveRatePerMinute number of proof-of-work entries, so all future new entries will increase the complexity. - for (let i = 0; i < desiredSolveCountPerMinute; i++) { - await proofOfWorkManager.recordProofOfWork(uuidv4()); + // stub that throws half the time + const stub = (): void => { + // Generate a random number between 0 and 1 + const random = Math.random(); + + // If the random number is less than 0.5, throw an error + if (random < 0.5) { + throw new Error('Random error'); } + }; - let lastMaximumAllowedHashValue = BigInt('0x' + initialMaximumHashValue); - for (let i = 0; i < 100; i++) { - // Simulating 1 proof-of-work per second which is ~60/min for 100 seconds. - clock.tick(1000); - await proofOfWorkManager.recordProofOfWork(uuidv4()); - await proofOfWorkManager.refreshMaximumAllowedHashValue(); + const challengeNonceRefreshSpy = sinon.stub(proofOfWorkManager, 'refreshChallengeNonce').callsFake(stub); + const maximumAllowedHashValueRefreshSpy = sinon.stub(proofOfWorkManager, 'refreshMaximumAllowedHashValue').callsFake(stub); - // The maximum allowed hash value should be decreasing as more proof-of-work is submitted. - expect(proofOfWorkManager.currentMaximumAllowedHashValue < lastMaximumAllowedHashValue).to.be.true; - lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; - } + // Simulated 1 hour has passed, so all proof-of-work entries should be removed. + clock.tick(60 * 60 * 1000); - // Simulated 100 seconds has passed, so all proof-of-work entries should be removed. - clock.tick(100_000); + // 1 hour divided by the challenge refresh frequency + const expectedChallengeNonceRefreshCount = 60 * 60 / proofOfWorkManager.challengeRefreshFrequencyInSeconds; - for (let i = 0; i < 100; i++) { - // Simulating no proof-of-work load for 100 seconds. - clock.tick(1000); - await proofOfWorkManager.refreshMaximumAllowedHashValue(); + // 1 hour divided by the challenge refresh frequency + const expectedDifficultyReevaluationCount = 60 * 60 / proofOfWorkManager.difficultyReevaluationFrequencyInSeconds; - // The maximum allowed hash value should be increasing again. - expect(proofOfWorkManager.currentMaximumAllowedHashValue > lastMaximumAllowedHashValue).to.be.true; - lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; - } + expect(challengeNonceRefreshSpy.callCount).to.greaterThanOrEqual(expectedChallengeNonceRefreshCount); + expect(maximumAllowedHashValueRefreshSpy.callCount).to.greaterThanOrEqual(expectedDifficultyReevaluationCount); + }); + + 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 initialMaximumHashValue = 'FFFFFFFF'; + const proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: true, + desiredSolveCountPerMinute, + initialMaximumHashValue, }); + + // Load up desiredSolveRatePerMinute number of proof-of-work entries, so all future new entries will increase the complexity. + for (let i = 0; i < desiredSolveCountPerMinute; i++) { + await proofOfWorkManager.recordProofOfWork(uuidv4()); + } + + let initialMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + let lastMaximumAllowedHashValue = BigInt('0x' + initialMaximumHashValue); + const lastSolveCountPerMinute = 0; + for (let i = 0; i < 100; i++) { + // Simulating 1 proof-of-work per second which for 100 seconds. + await proofOfWorkManager.recordProofOfWork(uuidv4()); + expect(proofOfWorkManager.currentSolveCountPerMinute).to.be.greaterThanOrEqual(lastSolveCountPerMinute); + clock.tick(1000); + + // The maximum allowed hash value should be monotonically decreasing as more proof-of-work is submitted. + expect(proofOfWorkManager.currentMaximumAllowedHashValue <= lastMaximumAllowedHashValue).to.be.true; + lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + } + expect(proofOfWorkManager.currentMaximumAllowedHashValue < initialMaximumAllowedHashValue).to.be.true; + + // Simulated 100 seconds has passed, so all proof-of-work entries should be removed. + clock.tick(100_000); + clock.runToLast(); + + expect(proofOfWorkManager.currentSolveCountPerMinute).to.equal(0); + + initialMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + for (let i = 0; i < 100; i++) { + // Simulating no proof-of-work load for 100 seconds. + clock.tick(1000); + + // The maximum allowed hash value should be monotonically increasing again. + expect(proofOfWorkManager.currentMaximumAllowedHashValue >= lastMaximumAllowedHashValue).to.be.true; + lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + } + expect(proofOfWorkManager.currentMaximumAllowedHashValue > initialMaximumAllowedHashValue).to.be.true; + }); + + it('should reduce difficulty back to initial difficulty when proof-of-work rate is lower than desired rate for long enough', async function () { + const desiredSolveCountPerMinute = 10; + const initialMaximumHashValue = 'FFFFFFFF'; + const initialMaximumHashValueAsBigInt = BigInt('0x' + initialMaximumHashValue); + const proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: true, + desiredSolveCountPerMinute, + initialMaximumHashValue, + }); + + // Load up desiredSolveRatePerMinute number of proof-of-work entries, so all future new entries will increase the complexity. + for (let i = 0; i < desiredSolveCountPerMinute; i++) { + await proofOfWorkManager.recordProofOfWork(uuidv4()); + } + + // Simulating 1 proof-of-work per second which for 100 seconds to increase proof-of-work difficulty. + for (let i = 0; i < 100; i++) { + await proofOfWorkManager.recordProofOfWork(uuidv4()); + clock.tick(1000); + } + expect(proofOfWorkManager.currentMaximumAllowedHashValue < initialMaximumHashValueAsBigInt).to.be.true; + + // Simulated 1 hour has passed. + clock.tick(60 * 60 * 1000); + clock.runToLast(); + + expect(proofOfWorkManager.currentMaximumAllowedHashValue === initialMaximumHashValueAsBigInt).to.be.true; }); }); diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts index e4bf6b7..fe380b6 100644 --- a/tests/scenarios/registration.spec.ts +++ b/tests/scenarios/registration.spec.ts @@ -63,7 +63,7 @@ describe('Registration scenarios', function () { const termsOfService = readFileSync(config.termsOfServiceFilePath).toString(); registrationManager = await RegistrationManager.create({ sqlDialect, termsOfService }); - dwn = await getTestDwn(registrationManager.getTenantGate()); + dwn = await getTestDwn(registrationManager); httpApi = new HttpApi(config, dwn, registrationManager); @@ -141,7 +141,7 @@ describe('Registration scenarios', function () { expect(registrationResponse.status).to.equal(200); // 6. Alice can now write to the DWN. - const { jsonRpcRequest, dataBytes } = await createRecordsWriteJsonRpcRequest(alice); + const { jsonRpcRequest, dataBytes } = await generateRecordsWriteJsonRpcRequest(alice); const writeResponse = await fetch(dwnMessageEndpoint, { method: 'POST', headers: { @@ -155,7 +155,7 @@ describe('Registration scenarios', function () { // 7. Sanity test that another non-tenant is NOT authorized to write. const nonTenant = await DidKeyResolver.generate(); - const nonTenantJsonRpcRequest = await createRecordsWriteJsonRpcRequest(nonTenant); + const nonTenantJsonRpcRequest = await generateRecordsWriteJsonRpcRequest(nonTenant); const nonTenantJsonRpcResponse = await fetch(dwnMessageEndpoint, { method: 'POST', headers: { @@ -168,9 +168,7 @@ describe('Registration scenarios', function () { expect(nonTenantJsonRpcResponseBody.result.reply.status.code).to.equal(401); }); - it('should reject a registration request that has proof-or-work that does not meet the difficulty requirement.', async function () { - // Scenario: // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. // 1. Alice computes the proof-of-work response nonce that is insufficient to meet the difficulty requirement. @@ -347,9 +345,53 @@ describe('Registration scenarios', function () { expect(registrationResponse.status).to.equal(400); expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerInvalidChallengeNonce); }); + + it('should reject a DWN message for an existing tenant who agreed to an outdated terms-of-service.', async () => { + // Scenario: + // 1. Alice is a registered tenant and is able to write to the DWN. + // 2. DWN server administrator updates the terms-of-service. + // 3. Alice no longer can write to the DWN because she has not agreed to the new terms-of-service. + + + // 1. Alice is a registered tenant and is able to write to the DWN. + // Short-cut to register Alice. + registrationManager.recordTenantRegistration({ + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([registrationManager.getTermsOfService()]) + }); + + // Sanity test that Alice can write to the DWN after registration. + const write1 = await generateRecordsWriteJsonRpcRequest(alice); + const write1Response = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(write1.jsonRpcRequest), + }, + body: new Blob([write1.dataBytes]), + }); + const write1ResponseBody = await write1Response.json() as JsonRpcResponse; + expect(write1Response.status).to.equal(200); + expect(write1ResponseBody.result.reply.status.code).to.equal(202); + + // 2. DWN server administrator updates the terms-of-service. + registrationManager.updateTermsOfService('new terms of service'); + + // 3. Alice no longer can write to the DWN because she has not agreed to the new terms-of-service. + const write2 = await generateRecordsWriteJsonRpcRequest(alice); + const write2Response = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(write2.jsonRpcRequest), + }, + body: new Blob([write2.dataBytes]), + }); + const write2ResponseBody = await write2Response.json() as JsonRpcResponse; + expect(write2Response.status).to.equal(200); + expect(write2ResponseBody.result.reply.status.code).to.equal(401); + }); }); -async function createRecordsWriteJsonRpcRequest(persona: Persona): Promise<{ jsonRpcRequest: JsonRpcRequest, dataBytes: Uint8Array }> { +async function generateRecordsWriteJsonRpcRequest(persona: Persona): Promise<{ jsonRpcRequest: JsonRpcRequest, dataBytes: Uint8Array }> { const { recordsWrite, dataStream } = await createRecordsWriteMessage(persona); const requestId = uuidv4(); From 6af9f4639a778fdfc3287ccc529a14913cff4d14 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Tue, 9 Jan 2024 16:33:27 -0800 Subject: [PATCH 13/27] Added detection of response nonce reuse + added documenation --- README.md | 4 +- src/dwn-error.ts | 1 + src/http-api.ts | 4 +- src/registration/proof-of-work-manager.ts | 94 +++++++++++++++++------ src/registration/proof-of-work-types.ts | 3 + src/registration/proof-of-work.ts | 28 ++++--- src/registration/registration-manager.ts | 27 ++++++- src/registration/registration-store.ts | 24 ++++-- src/registration/registration-types.ts | 6 ++ tests/scenarios/registration.spec.ts | 54 ++++++++++++- 10 files changed, 198 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 0ad2d77..74f5760 100644 --- a/README.md +++ b/README.md @@ -306,8 +306,8 @@ There are multiple optional registration gates, each of which can be enabled (al requirements are enabled before they are allowed to use the server. Tenants that have not completed the registration requirements will be met with a 401. Note that registration is tracked in a database, and only SQL-based databases are supported (LevelDB is not supported). Current registration requirements are available at the `/info.json` endpoint. -- **Proof of Work** (`DWN_REGISTRATION_PROOF_OF_WORK_ENABLED=true`) - new tenants must GET `/register/proof-of-work` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/register/proof-of-work` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info.json` as `proof-of-work-sha256-v0`. -- **Terms of Service** (`DWN_TERMS_OF_SERVICE_FILE_PATH=/path/to/terms-of-service.txt`) - new tenants must GET `/register/terms-of-service` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/register/terms-of-service`. The JSON body should have fields `termsOfServiceHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info.json` as `terms-of-service`. +- **Proof of Work** (`DWN_REGISTRATION_PROOF_OF_WORK_ENABLED=true`) - new tenants must GET `/registration/proof-of-work` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/registration/proof-of-work` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info.json` as `proof-of-work-sha256-v0`. +- **Terms of Service** (`DWN_TERMS_OF_SERVICE_FILE_PATH=/path/to/terms-of-service.txt`) - new tenants must GET `/registration/terms-of-service` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/registration/terms-of-service`. The JSON body should have fields `termsOfServiceHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info.json` as `terms-of-service`. ## Server info diff --git a/src/dwn-error.ts b/src/dwn-error.ts index 1db2da7..fa19159 100644 --- a/src/dwn-error.ts +++ b/src/dwn-error.ts @@ -30,6 +30,7 @@ export enum DwnServerErrorCode { ProofOfWorkInvalidOrExpiredChallenge = 'ProofOfWorkInvalidOrExpiredChallenge', ProofOfWorkManagerInvalidChallengeNonce = 'ProofOfWorkManagerInvalidChallengeNonce', ProofOfWorkManagerInvalidResponseNonceFormat = 'ProofOfWorkManagerInvalidResponseNonceFormat', + ProofOfWorkManagerResponseNonceReused = 'ProofOfWorkManagerResponseNonceReused', RegistrationManagerInvalidOrOutdatedTermsOfServiceHash = 'RegistrationManagerInvalidOrOutdatedTermsOfServiceHash', TenantRegistrationOutdatedTermsOfService = 'TenantRegistrationOutdatedTermsOfService', } diff --git a/src/http-api.ts b/src/http-api.ts index 52d24ae..87ae839 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -195,14 +195,14 @@ export class HttpApi { #setupRegistrationRoutes(): void { if (this.#config.registrationProofOfWorkEnabled) { - this.#api.get('/register/proof-of-work', async (_req: Request, res: Response) => { + this.#api.get('/registration/proof-of-work', async (_req: Request, res: Response) => { const proofOfWorkChallenge = this.registrationManager.getProofOfWorkChallenge(); res.json(proofOfWorkChallenge); }); } if (this.#config.termsOfServiceFilePath !== undefined) { - this.#api.get('/register/terms-of-service', (_req: Request, res: Response) => res.send(this.registrationManager.getTermsOfService())); + this.#api.get('/registration/terms-of-service', (_req: Request, res: Response) => res.send(this.registrationManager.getTermsOfService())); } this.#api.post('/registration', async (req: Request, res: Response) => { diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index a5ffe8b..932eb67 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -2,6 +2,10 @@ import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; import { ProofOfWork } from "./proof-of-work.js"; +/** + * Manages proof-of-work challenge difficulty and lifecycle based on solve rate. + * Can have multiple instances each having their own desired solve rate and difficulty. + */ export class ProofOfWorkManager { private challengeNonces: { currentChallengeNonce: string, previousChallengeNonce?: string }; private proofOfWorkOfLastMinute: Map = new Map(); // proofOfWorkId -> timestamp of proof-of-work @@ -9,13 +13,26 @@ export class ProofOfWorkManager { private initialMaximumHashValueAsBigInt: bigint; private desiredSolveCountPerMinute: number; + /** + * How often the challenge nonce is refreshed. + */ public challengeRefreshFrequencyInSeconds: number; + + /** + * How often the difficulty is reevaluated. + */ public difficultyReevaluationFrequencyInSeconds: number; + /** + * The current maximum allowed hash value. + */ public get currentMaximumAllowedHashValue(): bigint { return this.currentMaximumHashValueAsBigInt; } + /** + * The current proof-of-work solve rate. + */ public get currentSolveCountPerMinute(): number { return this.proofOfWorkOfLastMinute.size; } @@ -36,6 +53,11 @@ export class ProofOfWorkManager { this.difficultyReevaluationFrequencyInSeconds = input.difficultyReevaluationFrequencyInSeconds; } + /** + * Creates a new ProofOfWorkManager instance. + * @param input.challengeRefreshFrequencyInSeconds How often the challenge nonce is refreshed. Defaults to 10 minutes. + * @param input.difficultyReevaluationFrequencyInSeconds How often the difficulty is reevaluated. Defaults to 10 seconds. + */ public static async create(input: { desiredSolveCountPerMinute: number, initialMaximumHashValue: string, @@ -62,6 +84,14 @@ export class ProofOfWorkManager { return proofOfWorkManager; } + /** + * Starts the proof-of-work manager by starting the challenge nonce and difficulty refresh timers. + */ + public start(): void { + this.periodicallyRefreshChallengeNonce(); + this.periodicallyRefreshProofOfWorkDifficulty(); + } + public getProofOfWorkChallenge(): ProofOfWorkChallengeModel { return { challengeNonce: this.challengeNonces.currentChallengeNonce, @@ -70,22 +100,8 @@ export class ProofOfWorkManager { } /** - * Converts a BigInt to a 256 bit HEX string with padded preceding zeros (64 characters). + * Verifies the proof-of-work meets the difficulty requirement. */ - private static bigIntToHexString (int: BigInt): string { - let hex = int.toString(16).toUpperCase(); - const stringLength = hex.length; - for (let pad = stringLength; pad < 64; pad++) { - hex = '0' + hex; - } - return hex; - } - - public static isHexString(str: string): boolean { - const regexp = /^[0-9a-fA-F]+$/; - return regexp.test(str); - } - public async verifyProofOfWork(proofOfWork: { challengeNonce: string; responseNonce: string; @@ -93,6 +109,13 @@ export class ProofOfWorkManager { }): Promise { const { challengeNonce, responseNonce, requestData } = proofOfWork; + if (this.proofOfWorkOfLastMinute.has(responseNonce)) { + throw new DwnServerError( + DwnServerErrorCode.ProofOfWorkManagerResponseNonceReused, + `Not allowed to reused response nonce: ${responseNonce}.` + ); + } + // Verify response nonce is a HEX string that represents a 256 bit value. if (!ProofOfWorkManager.isHexString(responseNonce) || responseNonce.length !== 64) { throw new DwnServerError( @@ -112,13 +135,14 @@ export class ProofOfWorkManager { const maximumAllowedHashValue = this.currentMaximumAllowedHashValue; ProofOfWork.verifyResponseNonce({ challengeNonce, responseNonce, requestData, maximumAllowedHashValue }); - } - public start(): void { - this.periodicallyRefreshChallengeNonce(); - this.periodicallyRefreshProofOfWorkDifficulty(); + this.recordProofOfWork(responseNonce); } + /** + * Records a successful proof-of-work. + * Exposed for testing purposes. + */ public async recordProofOfWork(proofOfWorkId: string): Promise { this.proofOfWorkOfLastMinute.set(proofOfWorkId, Date.now()); } @@ -132,7 +156,7 @@ export class ProofOfWorkManager { setTimeout(async () => this.periodicallyRefreshChallengeNonce(), this.challengeRefreshFrequencyInSeconds * 1000); } } - + private periodicallyRefreshProofOfWorkDifficulty (): void { try { this.refreshMaximumAllowedHashValue(); @@ -143,7 +167,7 @@ export class ProofOfWorkManager { } } - public removeProofOfWorkOlderThanOneMinute (): void { + private removeProofOfWorkOlderThanOneMinute (): void { const oneMinuteAgo = Date.now() - 60 * 1000; for (const proofOfWorkId of this.proofOfWorkOfLastMinute.keys()) { if (this.proofOfWorkOfLastMinute.get(proofOfWorkId) < oneMinuteAgo) { @@ -165,8 +189,7 @@ export class ProofOfWorkManager { * If solve rate is lower than expected, the difficulty will decrease gradually. * The difficulty will never be lower than the initial difficulty. */ - private hashValueIncrementPerEvaluation = BigInt(1); - public async refreshMaximumAllowedHashValue (): Promise { + private async refreshMaximumAllowedHashValue (): Promise { // Cleanup proof-of-work cache and update solve rate. this.removeProofOfWorkOlderThanOneMinute(); @@ -225,4 +248,29 @@ export class ProofOfWorkManager { } } } + + /** + * Only used by refreshMaximumAllowedHashValue() to reduce the challenge difficulty gradually. + */ + private hashValueIncrementPerEvaluation = BigInt(1); + + /** + * Verifies that the supplied string is a HEX string. + */ + public static isHexString(str: string): boolean { + const regexp = /^[0-9a-fA-F]+$/; + return regexp.test(str); + } + + /** + * Converts a BigInt to a 256 bit HEX string with padded preceding zeros (64 characters). + */ + private static bigIntToHexString (int: BigInt): string { + let hex = int.toString(16).toUpperCase(); + const stringLength = hex.length; + for (let pad = stringLength; pad < 64; pad++) { + hex = '0' + hex; + } + return hex; + } } diff --git a/src/registration/proof-of-work-types.ts b/src/registration/proof-of-work-types.ts index 86939a8..ae75721 100644 --- a/src/registration/proof-of-work-types.ts +++ b/src/registration/proof-of-work-types.ts @@ -1,3 +1,6 @@ +/** + * Proof-of-work challenge model returned by the /registration/proof-of-work API. + */ export type ProofOfWorkChallengeModel = { challengeNonce: string; maximumAllowedHashValue: string; diff --git a/src/registration/proof-of-work.ts b/src/registration/proof-of-work.ts index 918ecc1..a1b53a1 100644 --- a/src/registration/proof-of-work.ts +++ b/src/registration/proof-of-work.ts @@ -2,21 +2,25 @@ import { createHash, randomBytes } from 'crypto'; import { DwnServerError, DwnServerErrorCode } from '../dwn-error.js'; +/** + * Utility methods related to proof-of-work. + */ export class ProofOfWork { + /** + * Computes the resulting hash of the given proof-of-work input. + */ public static computeHash(input: { challengeNonce: string; responseNonce: string; - requestData?: string; + requestData: string; }): string { - const hashInput = [input.challengeNonce, input.responseNonce]; - - if (input.requestData) { - hashInput.push(input.requestData); - } - + const hashInput = [input.challengeNonce, input.responseNonce, input.requestData]; return this.hashAsHexString(hashInput); } + /** + * Computes the hash of the given array of strings. + */ public static hashAsHexString(input: string[]): string { const hash = createHash('sha256'); for (const item of input) { @@ -26,11 +30,14 @@ export class ProofOfWork { return hash.digest('hex'); } + /** + * Verifies that the response nonce meets the proof-of-work difficulty requirement. + */ public static verifyResponseNonce(input: { maximumAllowedHashValue: bigint; challengeNonce: string; responseNonce: string; - requestData?: string; + requestData: string; }): void { const computedHash = this.computeHash(input); const computedHashAsBigInt = BigInt(`0x${computedHash}`); @@ -43,10 +50,13 @@ export class ProofOfWork { } } + /** + * Finds a response nonce that qualifies the difficulty requirement for the given proof-of-work challenge and request data. + */ public static findQualifiedResponseNonce(input: { maximumAllowedHashValue: string; challengeNonce: string; - requestData?: string; + requestData: string; }): string { const startTime = Date.now(); diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts index 0eae158..55d129d 100644 --- a/src/registration/registration-manager.ts +++ b/src/registration/registration-manager.ts @@ -7,6 +7,10 @@ import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; import type { TenantGate } from "@tbd54566975/dwn-sdk-js"; +/** + * The RegistrationManager is responsible for managing the registration of tenants. + * It handles tenant registration requests and provides the corresponding `TenantGate` implementation. + */ export class RegistrationManager implements TenantGate { private proofOfWorkManager: ProofOfWorkManager; private registrationStore: RegistrationStore; @@ -14,11 +18,17 @@ export class RegistrationManager implements TenantGate { private termsOfServiceHash?: string; private termsOfService?: string; - public getTermsOfService(): string { + /** + * The terms-of-service. + */ + public getTermsOfService(): string | undefined { return this.termsOfService; } - public getTermsOfServiceHash(): string { + /** + * The terms-of-service hash. + */ + public getTermsOfServiceHash(): string | undefined { return this.termsOfServiceHash; } @@ -36,6 +46,9 @@ export class RegistrationManager implements TenantGate { } } + /** + * Creates a new RegistrationManager instance. + */ public static async create(input: { sqlDialect: Dialect, termsOfService?: string @@ -57,12 +70,17 @@ export class RegistrationManager implements TenantGate { return registrationManager; } + /** + * Gets the proof-of-work challenge. + */ public getProofOfWorkChallenge(): ProofOfWorkChallengeModel { const proofOfWorkChallenge = this.proofOfWorkManager.getProofOfWorkChallenge(); return proofOfWorkChallenge; } - + /** + * Handles a registration request. + */ public async handleRegistrationRequest(registrationRequest: RegistrationRequest): Promise { // Ensure the supplied terms of service hash matches the one we require. if (registrationRequest.registrationData.termsOfServiceHash !== this.termsOfServiceHash) { @@ -91,6 +109,9 @@ export class RegistrationManager implements TenantGate { await this.registrationStore.insertOrUpdateTenantRegistration(registrationData); } + /** + * The TenantGate implementation. + */ public async isActiveTenant(tenant: string): Promise { const tenantRegistration = await this.registrationStore.getTenantRegistration(tenant); diff --git a/src/registration/registration-store.ts b/src/registration/registration-store.ts index 18b0fce..ea3770a 100644 --- a/src/registration/registration-store.ts +++ b/src/registration/registration-store.ts @@ -2,13 +2,21 @@ import { Kysely } from 'kysely'; import type { RegistrationData } from './registration-types.js'; import type { Dialect } from '@tbd54566975/dwn-sql-store'; +/** + * The RegistrationStore is responsible for storing and retrieving tenant registration information. + */ export class RegistrationStore { + private static readonly registeredTenantTableName = 'registeredTenants'; + private db: Kysely; private constructor (sqlDialect: Dialect) { this.db = new Kysely({ dialect: sqlDialect }); } + /** + * Creates a new RegistrationStore instance. + */ public static async create(sqlDialect: Dialect): Promise { const proofOfWorkManager = new RegistrationStore(sqlDialect); @@ -19,16 +27,19 @@ export class RegistrationStore { private async initialize(): Promise { await this.db.schema - .createTable('authorizedTenants') + .createTable(RegistrationStore.registeredTenantTableName) .ifNotExists() .addColumn('did', 'text', (column) => column.primaryKey()) .addColumn('termsOfServiceHash', 'boolean') .execute(); } + /** + * Inserts or updates the tenant registration information. + */ public async insertOrUpdateTenantRegistration(registrationData: RegistrationData): Promise { await this.db - .insertInto('authorizedTenants') + .insertInto(RegistrationStore.registeredTenantTableName) .values(registrationData) .onConflict((oc) => oc.column('did').doUpdateSet((eb) => ({ @@ -39,9 +50,12 @@ export class RegistrationStore { .executeTakeFirst(); } + /** + * Retrieves the tenant registration information. + */ public async getTenantRegistration(tenantDid: string): Promise { const result = await this.db - .selectFrom('authorizedTenants') + .selectFrom(RegistrationStore.registeredTenantTableName) .select('did') .select('termsOfServiceHash') .where('did', '=', tenantDid) @@ -55,11 +69,11 @@ export class RegistrationStore { } } -interface AuthorizedTenants { +interface RegisteredTenants { did: string; termsOfServiceHash: string; } interface RegistrationDatabase { - authorizedTenants: AuthorizedTenants; + registeredTenants: RegisteredTenants; } diff --git a/src/registration/registration-types.ts b/src/registration/registration-types.ts index 6a27c91..1714946 100644 --- a/src/registration/registration-types.ts +++ b/src/registration/registration-types.ts @@ -1,8 +1,14 @@ +/** + * Registration data model to be included as a parameter in the /registration POST request. + */ export type RegistrationData = { did: string; termsOfServiceHash: string; }; +/** + * Registration request model of the /registration POST API. + */ export type RegistrationRequest = { proofOfWork: { challengeNonce: string; diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts index fe380b6..d2c8540 100644 --- a/tests/scenarios/registration.spec.ts +++ b/tests/scenarios/registration.spec.ts @@ -41,8 +41,8 @@ if (!globalThis.crypto) { describe('Registration scenarios', function () { const dwnMessageEndpoint = 'http://localhost:3000'; - const termsOfUseEndpoint = 'http://localhost:3000/register/terms-of-service'; - const proofOfWorkEndpoint = 'http://localhost:3000/register/proof-of-work'; + const termsOfUseEndpoint = 'http://localhost:3000/registration/terms-of-service'; + const proofOfWorkEndpoint = 'http://localhost:3000/registration/proof-of-work'; const registrationEndpoint = 'http://localhost:3000/registration'; let httpApi: HttpApi; @@ -256,6 +256,55 @@ describe('Registration scenarios', function () { expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.RegistrationManagerInvalidOrOutdatedTermsOfServiceHash); }); + + it('should reject registration request that reuses a response nonce that is already used a short-time earlier', async () => { + // Scenario: + // 0. Assume Alice fetched the proof-of-work challenge and the terms-of-service. + // 1. Alice sends the registration request to the server and it is accepted. + // 2. Alice sends the same registration request which uses the same response nonce to the server again and it is rejected. + + // 0. Assume Alice fetched the proof-of-work challenge and the terms-of-service. + const { challengeNonce, maximumAllowedHashValue } = registrationManager.getProofOfWorkChallenge(); + const termsOfService = registrationManager.getTermsOfService(); + + // 1. Alice sends the registration request to the server and it is accepted. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfService]), + }; + + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + expect(registrationResponse.status).to.equal(200); + + // 2. Alice sends the same registration request which uses the same response nonce to the server again and it is rejected. + const registration2Response = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + const registration2ResponseBody = await registration2Response.json() as any; + expect(registration2Response.status).to.equal(400); + expect(registration2ResponseBody.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerResponseNonceReused); + }); + it('should reject an invalid nonce that is not a HEX string representing a 256 bit value.', async function () { // Assume Alice fetched the terms-of-service. @@ -352,7 +401,6 @@ describe('Registration scenarios', function () { // 2. DWN server administrator updates the terms-of-service. // 3. Alice no longer can write to the DWN because she has not agreed to the new terms-of-service. - // 1. Alice is a registered tenant and is able to write to the DWN. // Short-cut to register Alice. registrationManager.recordTenantRegistration({ From 8421ff136e8f7bf6bcbdca68f3234925538d7755 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Tue, 9 Jan 2024 18:44:04 -0800 Subject: [PATCH 14/27] Simplified how registration is configured --- README.md | 2 +- src/config.ts | 15 ++++------ src/dwn-server.ts | 20 +++++-------- src/http-api.ts | 36 ++++++++++++----------- src/registration/proof-of-work-manager.ts | 4 +++ src/registration/registration-manager.ts | 18 +++++++----- tests/http-api.spec.ts | 9 +++--- tests/process-handler.spec.ts | 4 +-- tests/scenarios/registration.spec.ts | 8 ++--- 9 files changed, 58 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 74f5760..23db08a 100644 --- a/README.md +++ b/README.md @@ -281,13 +281,13 @@ Configuration can be set using environment variables | `DS_PORT` | Port that the server listens on | `3000` | | `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 | `sqlite://data/dwn.db` | | `DWN_REGISTRATION_PROOF_OF_WORK_ENABLED` | Require new users to complete a proof-of-work challenge | `false` | | `DWN_TERMS_OF_SERVICE_FILE_PATH` | Required terms of service agreement if set. Value is path to the terms of service file. | unset | | `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | | `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | | `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | | `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | -| `DWN_STORAGE_REGISTRATION` | URL to use for storage of registered DIDs | `sqlite://data/dwn.db` | ### Storage Options diff --git a/src/config.ts b/src/config.ts index 6891bf5..d9027b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,17 +15,12 @@ export const config = { process.env.DWN_STORAGE_MESSAGES || process.env.DWN_STORAGE || 'level://data', - dataStore: - process.env.DWN_STORAGE_DATA || process.env.DWN_STORAGE || 'level://data', - eventLog: - process.env.DWN_STORAGE_EVENTS || process.env.DWN_STORAGE || 'level://data', - registrationProofOfWorkEnabled: - process.env.DWN_REGISTRATION_PROOF_OF_WORK_ENABLED === 'true', - tenantRegistrationStore: - process.env.DWN_STORAGE_REGISTRATION || - process.env.DWN_STORAGE || - 'sqlite://data/dwn.db', + dataStore: process.env.DWN_STORAGE_DATA || process.env.DWN_STORAGE || 'level://data', + eventLog: process.env.DWN_STORAGE_EVENTS || process.env.DWN_STORAGE || 'level://data', + // tenant registration feature configuration + registrationStoreUrl: process.env.DWN_REGISTRATION_STORE_URL || process.env.DWN_STORAGE || 'sqlite://data/dwn.db', + registrationProofOfWorkEnabled: process.env.DWN_REGISTRATION_PROOF_OF_WORK_ENABLED === 'true', termsOfServiceFilePath: process.env.DWN_TERMS_OF_SERVICE_FILE_PATH, // log level - trace/debug/info/warn/error diff --git a/src/dwn-server.ts b/src/dwn-server.ts index aba6b8f..65de5a5 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -1,6 +1,5 @@ import { Dwn } from '@tbd54566975/dwn-sdk-js'; -import { readFileSync } from 'fs'; import type { Server } from 'http'; import log from 'loglevel'; import prefix from 'loglevel-plugin-prefix'; @@ -11,7 +10,7 @@ import { HttpServerShutdownHandler } from './lib/http-server-shutdown-handler.js import { type Config, config as defaultConfig } from './config.js'; import { HttpApi } from './http-api.js'; import { setProcessHandlers } from './process-handlers.js'; -import { getDWNConfig, getDialectFromURI } from './storage.js'; +import { getDWNConfig } from './storage.js'; import { WsApi } from './ws-api.js'; import { RegistrationManager } from './registration/registration-manager.js'; @@ -27,6 +26,9 @@ export class DwnServer { #httpApi: HttpApi; #wsApi: WsApi; + /** + * @param options.dwn - Dwn instance to use as an override. Registration endpoint will not be enabled if this is provided. + */ constructor(options: DwnServerOptions = {}) { this.config = options.config ?? defaultConfig; this.dwn = options.dwn; @@ -48,19 +50,13 @@ export class DwnServer { * The DWN creation is secondary and only happens if it hasn't already been done. */ async #setupServer(): Promise { - // Load terms of service if given the path. - const termsOfService = - this.config.termsOfServiceFilePath !== undefined - ? readFileSync(this.config.termsOfServiceFilePath).toString() - : undefined; - - const tenantGateDB = getDialectFromURI( - new URL(this.config.tenantRegistrationStore), - ); let registrationManager: RegistrationManager; if (!this.dwn) { - registrationManager = await RegistrationManager.create({ sqlDialect: tenantGateDB, termsOfService }); + registrationManager = await RegistrationManager.create({ + registrationStoreUrl: this.config.registrationStoreUrl, + termsOfServiceFilePath: this.config.termsOfServiceFilePath, + }); this.dwn = await Dwn.create(getDWNConfig(this.config, registrationManager)); } diff --git a/src/http-api.ts b/src/http-api.ts index 87ae839..c27ae7e 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -205,24 +205,26 @@ export class HttpApi { this.#api.get('/registration/terms-of-service', (_req: Request, res: Response) => res.send(this.registrationManager.getTermsOfService())); } - this.#api.post('/registration', async (req: Request, res: Response) => { - const requestBody = req.body; - console.log('Registration request:', requestBody); - - try { - await this.registrationManager.handleRegistrationRequest(requestBody); - res.status(200).json({ success: true }); - } catch (error) { - const dwnServerError = error as DwnServerError; - - if (dwnServerError.code !== undefined) { - res.status(400).json(dwnServerError); - } else { - console.log('Error handling registration request:', error); - res.status(500).json({ success: false }); + if (this.#config.registrationProofOfWorkEnabled || this.#config.termsOfServiceFilePath !== undefined) { + this.#api.post('/registration', async (req: Request, res: Response) => { + const requestBody = req.body; + console.log('Registration request:', requestBody); + + try { + await this.registrationManager.handleRegistrationRequest(requestBody); + res.status(200).json({ success: true }); + } catch (error) { + const dwnServerError = error as DwnServerError; + + if (dwnServerError.code !== undefined) { + res.status(400).json(dwnServerError); + } else { + console.log('Error handling registration request:', error); + res.status(500).json({ success: false }); + } } - } - }); + }); + } } async start(port: number, callback?: () => void): Promise { diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index 932eb67..4cf5277 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -7,8 +7,12 @@ import { ProofOfWork } from "./proof-of-work.js"; * Can have multiple instances each having their own desired solve rate and difficulty. */ export class ProofOfWorkManager { + // Challenge nonces that can be used for proof-of-work. private challengeNonces: { currentChallengeNonce: string, previousChallengeNonce?: string }; + + // There is opportunity to improve implementation here. private proofOfWorkOfLastMinute: Map = new Map(); // proofOfWorkId -> timestamp of proof-of-work + private currentMaximumHashValueAsBigInt: bigint; private initialMaximumHashValueAsBigInt: bigint; private desiredSolveCountPerMinute: number; diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts index 55d129d..19662c0 100644 --- a/src/registration/registration-manager.ts +++ b/src/registration/registration-manager.ts @@ -1,4 +1,3 @@ -import type { Dialect } from "@tbd54566975/dwn-sql-store"; import { ProofOfWorkManager } from "./proof-of-work-manager.js"; import { ProofOfWork } from "./proof-of-work.js"; import { RegistrationStore } from "./registration-store.js"; @@ -6,6 +5,8 @@ import type { RegistrationData, RegistrationRequest } from "./registration-types import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; import type { TenantGate } from "@tbd54566975/dwn-sdk-js"; +import { getDialectFromURI } from "../storage.js"; +import { readFileSync } from "fs"; /** * The RegistrationManager is responsible for managing the registration of tenants. @@ -40,8 +41,9 @@ export class RegistrationManager implements TenantGate { this.termsOfService = termsOfService; } - private constructor (termsOfService?: string) { - if (termsOfService) { + private constructor (termsOfServiceFilePath?: string) { + if (termsOfServiceFilePath !== undefined) { + const termsOfService = readFileSync(termsOfServiceFilePath).toString(); this.updateTermsOfService(termsOfService); } } @@ -50,13 +52,14 @@ export class RegistrationManager implements TenantGate { * Creates a new RegistrationManager instance. */ public static async create(input: { - sqlDialect: Dialect, - termsOfService?: string + registrationStoreUrl: string, + termsOfServiceFilePath?: string }): Promise { - const { termsOfService, sqlDialect } = input; + const { termsOfServiceFilePath, registrationStoreUrl } = input; + + const registrationManager = new RegistrationManager(termsOfServiceFilePath); // Initialize and start ProofOfWorkManager. - const registrationManager = new RegistrationManager(termsOfService); registrationManager.proofOfWorkManager = await ProofOfWorkManager.create({ autoStart: true, desiredSolveCountPerMinute: 10, @@ -64,6 +67,7 @@ export class RegistrationManager implements TenantGate { }); // Initialize RegistrationStore. + const sqlDialect = getDialectFromURI(new URL(registrationStoreUrl)); const registrationStore = await RegistrationStore.create(sqlDialect); registrationManager.registrationStore = registrationStore; diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 66547aa..a9c024f 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -10,7 +10,6 @@ import { import type { Dwn } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; -import { readFileSync } from 'fs'; import type { Server } from 'http'; import fetch from 'node-fetch'; import { webcrypto } from 'node:crypto'; @@ -35,7 +34,6 @@ import { getFileAsReadStream, streamHttpRequest, } from './utils.js'; -import { getDialectFromURI } from '../src/storage.js'; import { RegistrationManager } from '../src/registration/registration-manager.js'; if (!globalThis.crypto) { @@ -54,13 +52,14 @@ describe('http api', function () { before(async function () { clock = useFakeTimers({ shouldAdvanceTime: true }); + config.registrationStoreUrl = 'sqlite://'; config.registrationProofOfWorkEnabled = true; config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; // RegistrationManager creation - const sqlDialect = getDialectFromURI(new URL('sqlite://')); - const termsOfService = readFileSync(config.termsOfServiceFilePath).toString(); - registrationManager = await RegistrationManager.create({ sqlDialect, termsOfService }); + const registrationStoreUrl = config.registrationStoreUrl; + const termsOfServiceFilePath = config.termsOfServiceFilePath; + registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath }); dwn = await getTestDwn(registrationManager); diff --git a/tests/process-handler.spec.ts b/tests/process-handler.spec.ts index 4110946..97dfe4d 100644 --- a/tests/process-handler.spec.ts +++ b/tests/process-handler.spec.ts @@ -10,8 +10,8 @@ describe('Process Handlers', function () { let processExitStub: sinon.SinonStub; beforeEach(async function () { - const testdwn = await getTestDwn(); - dwnServer = new DwnServer({ dwn: testdwn, config: config }); + const testDwn = await getTestDwn(); + dwnServer = new DwnServer({ dwn: testDwn, config: config }); await dwnServer.start(); processExitStub = sinon.stub(process, 'exit'); }); diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts index d2c8540..e1d7aef 100644 --- a/tests/scenarios/registration.spec.ts +++ b/tests/scenarios/registration.spec.ts @@ -30,7 +30,6 @@ import { import type { ProofOfWorkChallengeModel } from '../../src/registration/proof-of-work-types.js'; import type { RegistrationData, RegistrationRequest } from '../../src/registration/registration-types.js'; import { RegistrationManager } from '../../src/registration/registration-manager.js'; -import { getDialectFromURI } from '../../src/storage.js'; import { DwnServerErrorCode } from '../../src/dwn-error.js'; import { ProofOfWorkManager } from '../../src/registration/proof-of-work-manager.js'; @@ -55,13 +54,14 @@ describe('Registration scenarios', function () { before(async function () { clock = useFakeTimers({ shouldAdvanceTime: true }); + config.registrationStoreUrl = 'sqlite://'; config.registrationProofOfWorkEnabled = true; config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; // RegistrationManager creation - const sqlDialect = getDialectFromURI(new URL('sqlite://')); - const termsOfService = readFileSync(config.termsOfServiceFilePath).toString(); - registrationManager = await RegistrationManager.create({ sqlDialect, termsOfService }); + const registrationStoreUrl = config.registrationStoreUrl; + const termsOfServiceFilePath = config.termsOfServiceFilePath; + registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath }); dwn = await getTestDwn(registrationManager); From ea6a505eb5550eba8ac1046f73c8b9ccf2973723 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Tue, 9 Jan 2024 19:25:39 -0800 Subject: [PATCH 15/27] Added a few logs to make debugging server environment issues easier --- src/config.ts | 8 ++------ src/http-api.ts | 2 ++ src/storage.ts | 4 +++- tests/cors.spec.ts | 6 ++++++ tests/dwn-server.spec.ts | 6 ------ 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/config.ts b/src/config.ts index d9027b9..0a3655a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,13 +8,9 @@ export const config = { // port that server listens on port: parseInt(process.env.DS_PORT || '3000'), // whether to enable 'ws:' - webSocketServerEnabled: - { on: true, off: false }[process.env.DS_WEBSOCKET_SERVER] ?? true, + webSocketServerEnabled: { on: true, off: false }[process.env.DS_WEBSOCKET_SERVER] ?? true, // where to store persistent data - messageStore: - process.env.DWN_STORAGE_MESSAGES || - process.env.DWN_STORAGE || - 'level://data', + messageStore: process.env.DWN_STORAGE_MESSAGES || process.env.DWN_STORAGE || 'level://data', dataStore: process.env.DWN_STORAGE_DATA || process.env.DWN_STORAGE || 'level://data', eventLog: process.env.DWN_STORAGE_EVENTS || process.env.DWN_STORAGE || 'level://data', diff --git a/src/http-api.ts b/src/http-api.ts index c27ae7e..f10cf5c 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -31,6 +31,8 @@ export class HttpApi { dwn: Dwn; constructor(config: Config, dwn: Dwn, registrationManager: RegistrationManager) { + console.log(config); + this.#config = config; this.#api = express(); this.#server = http.createServer(this.#api); diff --git a/src/storage.ts b/src/storage.ts index 2133c7a..d8c8642 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -127,8 +127,10 @@ function getStore(storeString: string, storeType: EStoreType): StoreType { export function getDialectFromURI(u: URL): Dialect { switch (u.protocol.slice(0, -1)) { case BackendTypes.SQLITE: + const path = u.host + u.pathname; + console.log('Relative SQL-lite path:', path); return new SqliteDialect({ - database: async () => new Database(u.host + u.pathname), + database: async () => new Database(path), }); case BackendTypes.MYSQL: return new MysqlDialect({ diff --git a/tests/cors.spec.ts b/tests/cors.spec.ts index 69f6d54..9b9649c 100644 --- a/tests/cors.spec.ts +++ b/tests/cors.spec.ts @@ -171,6 +171,12 @@ describe('CORS setup', function () { // dwn-server runs on dwn.localhost const proxy = new CorsProxySetup(); before(async () => { + + // Mute all server console logs during tests. + console.log = (): void => {}; + console.error = (): void => {}; + console.info = (): void => {}; + await proxy.start(); }); after(async () => { diff --git a/tests/dwn-server.spec.ts b/tests/dwn-server.spec.ts index db3c501..b3e5e7a 100644 --- a/tests/dwn-server.spec.ts +++ b/tests/dwn-server.spec.ts @@ -7,12 +7,6 @@ import { getTestDwn } from './test-dwn.js'; describe('DwnServer', function () { let dwnServer: DwnServer; before(async function () { - - // Mute all server console logs during tests. - console.log = (): void => {}; - console.error = (): void => {}; - console.info = (): void => {}; - const testDwn = await getTestDwn(); dwnServer = new DwnServer({ dwn: testDwn, config: config }); }); From a551cb2a6c37e1d267d1836e2af3c29482165e2a Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Tue, 9 Jan 2024 19:35:00 -0800 Subject: [PATCH 16/27] Minor update --- src/http-api.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/http-api.ts b/src/http-api.ts index f10cf5c..88d72bd 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -61,7 +61,10 @@ export class HttpApi { this.#api.use( responseTime((req: Request, res: Response, time) => { const url = req.url === '/' ? '/jsonrpc' : req.url; - const route = (req.method + url).toLowerCase().replace(/[:.]/g, '').replace(/\//g, '_'); + const route = (req.method + url) + .toLowerCase() + .replace(/[:.]/g, '') + .replace(/\//g, '_'); const statusCode = res.statusCode.toString(); responseHistogram.labels(route, statusCode).observe(time); From 9ce8c6ce028782f9a6d5b6dba3719062f5a6671a Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Tue, 9 Jan 2024 19:39:05 -0800 Subject: [PATCH 17/27] paackage-lock.json --- package-lock.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1302bf0..07a1379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@web5/dwn-server", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@web5/dwn-server", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@tbd54566975/dwn-sdk-js": "0.2.11", - "@tbd54566975/dwn-sql-store": "0.2.5", + "@tbd54566975/dwn-sql-store": "0.2.6", "better-sqlite3": "^8.5.0", "body-parser": "^1.20.2", "bytes": "3.1.2", @@ -610,12 +610,12 @@ } }, "node_modules/@tbd54566975/dwn-sql-store": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sql-store/-/dwn-sql-store-0.2.5.tgz", - "integrity": "sha512-WA0dqVhidBg1EKMyYGMXo1HDoaPDKRjycKwpBNj0Hgt9zUwRG1PvqP1m5xFzTI+wryJEVvew/KkRICkz67sLLQ==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sql-store/-/dwn-sql-store-0.2.6.tgz", + "integrity": "sha512-N5SSyKGgHoW7ttWW6xrPq4xK7aYfxDvrmXdYEi+eA3qlT+Wzi5HZfrlcSnFIHee9+s56V6WpJw1AQ6XmjZH2QQ==", "dependencies": { "@ipld/dag-cbor": "^9.0.5", - "@tbd54566975/dwn-sdk-js": "0.2.9", + "@tbd54566975/dwn-sdk-js": "0.2.10", "kysely": "0.26.3", "multiformats": "12.0.1", "readable-stream": "4.4.2" @@ -638,9 +638,9 @@ } }, "node_modules/@tbd54566975/dwn-sql-store/node_modules/@tbd54566975/dwn-sdk-js": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.2.9.tgz", - "integrity": "sha512-p9wv0GrNq0BgUMvlS54osLKAZE/WhTV89Oh0nvGUk9NH5iwvswlbG4aygw4vDqdVo7XruzfGienW0X/eNWZL0g==", + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.2.10.tgz", + "integrity": "sha512-CoKO8+NciwWNzD4xRoAAgeElqQCXKM4Fc+zEHsUWD0M3E9v67hRWiTHI6AenUfQv1RSEB2H4GHUeUOHuEV72uw==", "dependencies": { "@ipld/dag-cbor": "9.0.3", "@js-temporal/polyfill": "0.4.4", @@ -3985,9 +3985,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "dev": true, "funding": [ { From 0374ec0484216a1a901eaaaf975950ef77903275 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Tue, 9 Jan 2024 23:50:22 -0800 Subject: [PATCH 18/27] Removed Proifle type --- tests/http-api.spec.ts | 61 +++++++++++++++++++++--------------------- tests/utils.ts | 13 ++------- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index a9c024f..3fb4d75 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -7,7 +7,7 @@ import { RecordsRead, Time, } from '@tbd54566975/dwn-sdk-js'; -import type { Dwn } from '@tbd54566975/dwn-sdk-js'; +import type { Dwn, Persona } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; import type { Server } from 'http'; @@ -28,7 +28,6 @@ import { JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; import { getTestDwn } from './test-dwn.js'; -import type { Profile } from './utils.js'; import { createRecordsWriteMessage, getFileAsReadStream, @@ -44,7 +43,7 @@ if (!globalThis.crypto) { describe('http api', function () { let httpApi: HttpApi; let server: Server; - let profile: Profile; + let alice: Persona; let registrationManager: RegistrationManager; let dwn: Dwn; let clock; @@ -65,8 +64,8 @@ describe('http api', function () { httpApi = new HttpApi(config, dwn, registrationManager); - profile = await DidKeyResolver.generate(); - await registrationManager.recordTenantRegistration({ did: profile.did, termsOfServiceHash: registrationManager.getTermsOfServiceHash()}); + alice = await DidKeyResolver.generate(); + await registrationManager.recordTenantRegistration({ did: alice.did, termsOfServiceHash: registrationManager.getTermsOfServiceHash()}); }); beforeEach(async function () { @@ -108,7 +107,7 @@ describe('http api', function () { it('responds with a 2XX HTTP status if JSON RPC handler returns 4XX/5XX DWN status code', async function () { const { recordsWrite, dataStream } = - await createRecordsWriteMessage(profile); + await createRecordsWriteMessage(alice); // Intentionally delete a required property to produce an invalid RecordsWrite message. const message = recordsWrite.toJSON(); @@ -117,7 +116,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: message, - target: profile.did, + target: alice.did, }); const dataBytes = await DataStream.toBytes(dataStream); @@ -171,13 +170,13 @@ describe('http api', function () { filter: { schema: 'woosa', }, - signer: profile.signer, + signer: alice.signer, }); const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsQuery.toJSON(), - target: profile.did, + target: alice.did, }); const response = await request(httpApi.api) @@ -197,7 +196,7 @@ describe('http api', function () { const filePath = './fixtures/test.jpeg'; const { cid, size, stream } = await getFileAsReadStream(filePath); - const { recordsWrite } = await createRecordsWriteMessage(profile, { + const { recordsWrite } = await createRecordsWriteMessage(alice, { dataCid: cid, dataSize: size, }); @@ -205,7 +204,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: profile.did, + target: alice.did, }); const resp = await streamHttpRequest( @@ -233,12 +232,12 @@ describe('http api', function () { it('handles RecordsWrite overwrite that does not mutate data', async function () { // First RecordsWrite that creates the record. const { recordsWrite: initialWrite, dataStream } = - await createRecordsWriteMessage(profile); + await createRecordsWriteMessage(alice); const dataBytes = await DataStream.toBytes(dataStream); let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: initialWrite.toJSON(), - target: profile.did, + target: alice.did, }); const responseInitialWrite = await fetch('http://localhost:3000', { @@ -255,7 +254,7 @@ describe('http api', function () { await Time.minimalSleep(); // Subsequent RecordsWrite that mutates the published property of the record. - const { recordsWrite: overWrite } = await createRecordsWriteMessage(profile, { + const { recordsWrite: overWrite } = await createRecordsWriteMessage(alice, { recordId: initialWrite.message.recordId, dataCid: initialWrite.message.descriptor.dataCid, dataSize: initialWrite.message.descriptor.dataSize, @@ -266,7 +265,7 @@ describe('http api', function () { requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: overWrite.toJSON(), - target: profile.did, + target: alice.did, }); const responseOverwrite = await fetch('http://localhost:3000', { method: 'POST', @@ -289,12 +288,12 @@ describe('http api', function () { it('handles a RecordsWrite tombstone', async function () { const { recordsWrite: tombstone } = - await createRecordsWriteMessage(profile); + await createRecordsWriteMessage(alice); const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: tombstone.toJSON(), - target: profile.did, + target: alice.did, }); const responeTombstone = await fetch('http://localhost:3000', { @@ -335,7 +334,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const { recordsWrite } = await createRecordsWriteMessage(profile, { + const { recordsWrite } = await createRecordsWriteMessage(alice, { dataCid: expectedCid, dataSize: size, }); @@ -343,7 +342,7 @@ describe('http api', function () { let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: profile.did, + target: alice.did, }); let response = await fetch('http://localhost:3000', { @@ -364,7 +363,7 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); const recordsRead = await RecordsRead.create({ - signer: profile.signer, + signer: alice.signer, filter: { recordId: recordsWrite.message.recordId, }, @@ -372,7 +371,7 @@ describe('http api', function () { requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - target: profile.did, + target: alice.did, message: recordsRead.toJSON(), }); @@ -418,7 +417,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const { recordsWrite } = await createRecordsWriteMessage(profile, { + const { recordsWrite } = await createRecordsWriteMessage(alice, { dataCid: expectedCid, dataSize: size, published: true, @@ -427,7 +426,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: profile.did, + target: alice.did, }); let response = await fetch('http://localhost:3000', { @@ -448,7 +447,7 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); response = await fetch( - `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, ); const blob = await response.blob(); @@ -463,7 +462,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const { recordsWrite } = await createRecordsWriteMessage(profile, { + const { recordsWrite } = await createRecordsWriteMessage(alice, { dataCid: expectedCid, dataSize: size, }); @@ -471,7 +470,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: profile.did, + target: alice.did, }); let response = await fetch('http://localhost:3000', { @@ -492,17 +491,17 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); response = await fetch( - `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); - it('returns a 404 if record doesnt exist', async function () { - const { recordsWrite } = await createRecordsWriteMessage(profile); + it('returns a 404 if record does not exist', async function () { + const { recordsWrite } = await createRecordsWriteMessage(alice); const response = await fetch( - `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); @@ -519,7 +518,7 @@ describe('http api', function () { it('returns a 404 for invalid record id', async function () { const response = await fetch( - `http://localhost:3000/${profile.did}/records/kaka`, + `http://localhost:3000/${alice.did}/records/kaka`, ); expect(response.status).to.equal(404); }); diff --git a/tests/utils.ts b/tests/utils.ts index 5cb1d9b..3ded3c4 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,4 +1,4 @@ -import type { PrivateJwk, PublicJwk, Signer } from '@tbd54566975/dwn-sdk-js'; +import type { Persona } from '@tbd54566975/dwn-sdk-js'; import { Cid, DataStream, RecordsWrite } from '@tbd54566975/dwn-sdk-js'; import type { ReadStream } from 'node:fs'; @@ -13,15 +13,6 @@ import { WebSocket } from 'ws'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -export type Profile = { - did: string; - keyPair: { - publicJwk: PublicJwk; - privateJwk: PrivateJwk; - }; - signer: Signer; -}; - export type CreateRecordsWriteOverrides = | ({ dataCid?: string; @@ -44,7 +35,7 @@ export type GenerateProtocolsConfigureOutput = { }; export async function createRecordsWriteMessage( - signer: Profile, + signer: Persona, overrides: CreateRecordsWriteOverrides = {}, ): Promise { if (!overrides.dataCid && !overrides.data) { From b705b4827f85aa8db921feda022284459efe7db9 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 11 Jan 2024 11:11:00 -0800 Subject: [PATCH 19/27] Renamed /info.json -> /info --- README.md | 8 ++++---- src/http-api.ts | 2 +- tests/http-api.spec.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 23db08a..28d4f32 100644 --- a/README.md +++ b/README.md @@ -304,14 +304,14 @@ Several storage formats are supported, and may be configured with the `DWN_STORA There are multiple optional registration gates, each of which can be enabled (all are disabled by default). Tenants (DIDs) must comply with whatever requirements are enabled before they are allowed to use the server. Tenants that have not completed the registration requirements will be met with a 401. Note that registration is tracked in a database, and only SQL-based databases are supported (LevelDB is not supported). Current registration -requirements are available at the `/info.json` endpoint. +requirements are available at the `/info` endpoint. -- **Proof of Work** (`DWN_REGISTRATION_PROOF_OF_WORK_ENABLED=true`) - new tenants must GET `/registration/proof-of-work` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/registration/proof-of-work` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info.json` as `proof-of-work-sha256-v0`. -- **Terms of Service** (`DWN_TERMS_OF_SERVICE_FILE_PATH=/path/to/terms-of-service.txt`) - new tenants must GET `/registration/terms-of-service` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/registration/terms-of-service`. The JSON body should have fields `termsOfServiceHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info.json` as `terms-of-service`. +- **Proof of Work** (`DWN_REGISTRATION_PROOF_OF_WORK_ENABLED=true`) - new tenants must GET `/registration/proof-of-work` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/registration/proof-of-work` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info` as `proof-of-work-sha256-v0`. +- **Terms of Service** (`DWN_TERMS_OF_SERVICE_FILE_PATH=/path/to/terms-of-service.txt`) - new tenants must GET `/registration/terms-of-service` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/registration/terms-of-service`. The JSON body should have fields `termsOfServiceHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info` as `terms-of-service`. ## Server info -the server exposes information about itself via the `/info.json` endpoint, which returns data in the following format: +the server exposes information about itself via the `/info` endpoint, which returns data in the following format: ```json { diff --git a/src/http-api.ts b/src/http-api.ts index 88d72bd..351e863 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -174,7 +174,7 @@ export class HttpApi { this.#setupRegistrationRoutes(); - this.#api.get('/info.json', (req, res) => { + this.#api.get('/info', (req, res) => { res.setHeader('content-type', 'application/json'); const registrationRequirements: string[] = []; if (config.registrationProofOfWorkEnabled) { diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 3fb4d75..0afeaa4 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -524,9 +524,9 @@ describe('http api', function () { }); }); - describe('/info.json', function () { - it('verify /info.json has some of the fields it is supposed to have', async function () { - const resp = await fetch(`http://localhost:3000/info.json`); + describe('/info', function () { + it('verify /info has some of the fields it is supposed to have', async function () { + const resp = await fetch(`http://localhost:3000/info`); expect(resp.status).to.equal(200); const info = await resp.json(); From deadd00c3682aa099ab8a9fac140f9fb91cfc5e2 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 11 Jan 2024 11:30:29 -0800 Subject: [PATCH 20/27] Moved currentSolveRateInFractionOfDesiredSolveRate initialization --- src/registration/proof-of-work-manager.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index 4cf5277..ac36137 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -205,16 +205,14 @@ export class ProofOfWorkManager { // NOTE: easier difficulty is represented by a larger max allowed hash value // and harder difficulty is represented by a smaller max allowed hash value. - const currentSolveRateInFractionOfDesiredSolveRate = latestSolveCountPerMinute / this.desiredSolveCountPerMinute; if (latestSolveCountPerMinute > this.desiredSolveCountPerMinute) { - this.hashValueIncrementPerEvaluation = undefined; - // if solve rate is higher than desired, make difficulty harder by making the max allowed hash value smaller // set higher to make difficulty increase faster. // This should also be relative to how often the difficulty is reevaluated if the reevaluation frequency is adjustable. const increaseMultiplier = 1; - + + const currentSolveRateInFractionOfDesiredSolveRate = latestSolveCountPerMinute / this.desiredSolveCountPerMinute; const newMaximumHashValueAsBigIntPriorToMultiplierAdjustment = (this.currentMaximumHashValueAsBigInt * BigInt(scaleFactor)) / (BigInt(Math.floor(currentSolveRateInFractionOfDesiredSolveRate * increaseMultiplier * scaleFactor))); @@ -228,6 +226,9 @@ export class ProofOfWorkManager { const hashValueDecreaseAmount = hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment / BigInt(difficultyEvaluationsPerMinute); this.currentMaximumHashValueAsBigInt -= hashValueDecreaseAmount; + + // Resetting to allow hash increment to be recalculated when difficulty needs to be reduced (in `else` block below) + this.hashValueIncrementPerEvaluation = undefined; } else { // if solve rate is lower than desired, make difficulty easier by making the max allowed hash value larger From 54c128be3be748214e0a81ee17536b7a9421e9f5 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 11 Jan 2024 11:42:07 -0800 Subject: [PATCH 21/27] Exposed difficultyIncreaseMultiplier --- src/registration/proof-of-work-manager.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index ac36137..467da08 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -13,6 +13,7 @@ export class ProofOfWorkManager { // There is opportunity to improve implementation here. private proofOfWorkOfLastMinute: Map = new Map(); // proofOfWorkId -> timestamp of proof-of-work + private difficultyIncreaseMultiplier: number; private currentMaximumHashValueAsBigInt: bigint; private initialMaximumHashValueAsBigInt: bigint; private desiredSolveCountPerMinute: number; @@ -44,6 +45,7 @@ export class ProofOfWorkManager { private constructor (input: { desiredSolveCountPerMinute: number, initialMaximumHashValue: string, + difficultyIncreaseMultiplier: number, challengeRefreshFrequencyInSeconds: number, difficultyReevaluationFrequencyInSeconds: number }) { @@ -53,12 +55,16 @@ export class ProofOfWorkManager { this.currentMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); this.initialMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); this.desiredSolveCountPerMinute = desiredSolveCountPerMinute; + this.difficultyIncreaseMultiplier = input.difficultyIncreaseMultiplier; this.challengeRefreshFrequencyInSeconds = input.challengeRefreshFrequencyInSeconds; this.difficultyReevaluationFrequencyInSeconds = input.difficultyReevaluationFrequencyInSeconds; } /** * Creates a new ProofOfWorkManager instance. + * @param input.difficultyIncreaseMultiplier How fast to increase difficulty when solve rate is higher than desired. Must be >= 1. + * Defaults to 1 which means if the solve rate is 2x the desired solve rate, the difficulty will increase by 2x. + * If set to 2, it means if the solve rate is 2x the desired solve rate, the difficulty will increase by 4x. * @param input.challengeRefreshFrequencyInSeconds How often the challenge nonce is refreshed. Defaults to 10 minutes. * @param input.difficultyReevaluationFrequencyInSeconds How often the difficulty is reevaluated. Defaults to 10 seconds. */ @@ -66,17 +72,20 @@ export class ProofOfWorkManager { desiredSolveCountPerMinute: number, initialMaximumHashValue: string, autoStart: boolean, + difficultyIncreaseMultiplier?: number, challengeRefreshFrequencyInSeconds?: number, difficultyReevaluationFrequencyInSeconds?: number }): Promise { const { desiredSolveCountPerMinute, initialMaximumHashValue } = input; + const difficultyIncreaseMultiplier = input.difficultyIncreaseMultiplier ?? 1; // 1x default const challengeRefreshFrequencyInSeconds = input.challengeRefreshFrequencyInSeconds ?? 10 * 60; // 10 minutes default const difficultyReevaluationFrequencyInSeconds = input.difficultyReevaluationFrequencyInSeconds ?? 10; // 10 seconds default const proofOfWorkManager = new ProofOfWorkManager({ desiredSolveCountPerMinute, initialMaximumHashValue, + difficultyIncreaseMultiplier, challengeRefreshFrequencyInSeconds, difficultyReevaluationFrequencyInSeconds }); @@ -208,20 +217,15 @@ export class ProofOfWorkManager { if (latestSolveCountPerMinute > this.desiredSolveCountPerMinute) { // if solve rate is higher than desired, make difficulty harder by making the max allowed hash value smaller - // set higher to make difficulty increase faster. - // This should also be relative to how often the difficulty is reevaluated if the reevaluation frequency is adjustable. - const increaseMultiplier = 1; - const currentSolveRateInFractionOfDesiredSolveRate = latestSolveCountPerMinute / this.desiredSolveCountPerMinute; const newMaximumHashValueAsBigIntPriorToMultiplierAdjustment = (this.currentMaximumHashValueAsBigInt * BigInt(scaleFactor)) / - (BigInt(Math.floor(currentSolveRateInFractionOfDesiredSolveRate * increaseMultiplier * scaleFactor))); + (BigInt(Math.floor(currentSolveRateInFractionOfDesiredSolveRate * this.difficultyIncreaseMultiplier * scaleFactor))); - // set higher to make difficulty increase faster. // This should also be relative to how often the difficulty is reevaluated if the reevaluation frequency is adjustable. const hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment = (this.currentMaximumHashValueAsBigInt - newMaximumHashValueAsBigIntPriorToMultiplierAdjustment) * - (BigInt(Math.floor(increaseMultiplier * scaleFactor)) / BigInt(scaleFactor)); + (BigInt(Math.floor(this.difficultyIncreaseMultiplier * scaleFactor)) / BigInt(scaleFactor)); const hashValueDecreaseAmount = hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment / BigInt(difficultyEvaluationsPerMinute); From b6dfc1129341ee2746f188887814557d2800f027 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 11 Jan 2024 11:47:16 -0800 Subject: [PATCH 22/27] Updated comment --- src/registration/proof-of-work-manager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index 467da08..9969b22 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -222,11 +222,11 @@ export class ProofOfWorkManager { = (this.currentMaximumHashValueAsBigInt * BigInt(scaleFactor)) / (BigInt(Math.floor(currentSolveRateInFractionOfDesiredSolveRate * this.difficultyIncreaseMultiplier * scaleFactor))); - // This should also be relative to how often the difficulty is reevaluated if the reevaluation frequency is adjustable. const hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment - = (this.currentMaximumHashValueAsBigInt - newMaximumHashValueAsBigIntPriorToMultiplierAdjustment) * - (BigInt(Math.floor(this.difficultyIncreaseMultiplier * scaleFactor)) / BigInt(scaleFactor)); - + = (this.currentMaximumHashValueAsBigInt - newMaximumHashValueAsBigIntPriorToMultiplierAdjustment) * + (BigInt(Math.floor(this.difficultyIncreaseMultiplier * scaleFactor)) / BigInt(scaleFactor)); + + // Adjustment based on the reevaluation frequency to provide more-or-less consistent behavior regardless of the reevaluation frequency. const hashValueDecreaseAmount = hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment / BigInt(difficultyEvaluationsPerMinute); this.currentMaximumHashValueAsBigInt -= hashValueDecreaseAmount; From 33b12b568cbde23d327b220f0ef386163b7077dc Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 11 Jan 2024 14:23:09 -0800 Subject: [PATCH 23/27] Exposed DWN_REGISTRATION_PROOF_OF_WORK_INITIAL_MAX_HASH setting --- README.md | 25 ++++++----- src/config.ts | 1 + src/dwn-server.ts | 1 + src/registration/proof-of-work-manager.ts | 45 ++++++++++--------- src/registration/registration-manager.ts | 5 ++- tests/dwn-server.spec.ts | 7 +-- tests/http-api.spec.ts | 4 +- .../proof-of-work-manager.spec.ts | 28 ++++++------ tests/scenarios/registration.spec.ts | 14 +++--- 9 files changed, 72 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 28d4f32..7bf861d 100644 --- a/README.md +++ b/README.md @@ -276,18 +276,19 @@ cloudflared tunnel --url http://localhost:3000 Configuration can be set using environment variables -| Env Var | Description | Default | -| ---------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------- | -| `DS_PORT` | Port that the server listens on | `3000` | -| `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 | `sqlite://data/dwn.db` | -| `DWN_REGISTRATION_PROOF_OF_WORK_ENABLED` | Require new users to complete a proof-of-work challenge | `false` | -| `DWN_TERMS_OF_SERVICE_FILE_PATH` | Required terms of service agreement if set. Value is path to the terms of service file. | unset | -| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | -| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | -| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | -| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | +| Env Var | Description | Default | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| `DS_PORT` | Port that the server listens on | `3000` | +| `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 | `sqlite://data/dwn.db` | +| `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 | +| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | +| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | +| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | ### Storage Options diff --git a/src/config.ts b/src/config.ts index 0a3655a..44d71da 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,7 @@ export const config = { // tenant registration feature configuration registrationStoreUrl: process.env.DWN_REGISTRATION_STORE_URL || process.env.DWN_STORAGE || 'sqlite://data/dwn.db', 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, // log level - trace/debug/info/warn/error diff --git a/src/dwn-server.ts b/src/dwn-server.ts index 65de5a5..8d55674 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -56,6 +56,7 @@ export class DwnServer { registrationManager = await RegistrationManager.create({ registrationStoreUrl: this.config.registrationStoreUrl, termsOfServiceFilePath: this.config.termsOfServiceFilePath, + initialMaximumAllowedHashValue: this.config.registrationProofOfWorkInitialMaxHash, }); this.dwn = await Dwn.create(getDWNConfig(this.config, registrationManager)); diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index 9969b22..c7fbed3 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -7,6 +7,9 @@ import { ProofOfWork } from "./proof-of-work.js"; * Can have multiple instances each having their own desired solve rate and difficulty. */ export class ProofOfWorkManager { + // Takes from seconds to ~1 minute to solve on an M1 MacBook. + private static readonly defaultMaximumAllowedHashValue = '000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; + // Challenge nonces that can be used for proof-of-work. private challengeNonces: { currentChallengeNonce: string, previousChallengeNonce?: string }; @@ -14,8 +17,8 @@ export class ProofOfWorkManager { private proofOfWorkOfLastMinute: Map = new Map(); // proofOfWorkId -> timestamp of proof-of-work private difficultyIncreaseMultiplier: number; - private currentMaximumHashValueAsBigInt: bigint; - private initialMaximumHashValueAsBigInt: bigint; + private currentMaximumAllowedHashValueAsBigInt: bigint; + private initialMaximumAllowedHashValueAsBigInt: bigint; private desiredSolveCountPerMinute: number; /** @@ -32,7 +35,7 @@ export class ProofOfWorkManager { * The current maximum allowed hash value. */ public get currentMaximumAllowedHashValue(): bigint { - return this.currentMaximumHashValueAsBigInt; + return this.currentMaximumAllowedHashValueAsBigInt; } /** @@ -44,16 +47,16 @@ export class ProofOfWorkManager { private constructor (input: { desiredSolveCountPerMinute: number, - initialMaximumHashValue: string, + initialMaximumAllowedHashValue: string, difficultyIncreaseMultiplier: number, challengeRefreshFrequencyInSeconds: number, difficultyReevaluationFrequencyInSeconds: number }) { - const { desiredSolveCountPerMinute, initialMaximumHashValue } = input; + const { desiredSolveCountPerMinute, initialMaximumAllowedHashValue } = input; this.challengeNonces = { currentChallengeNonce: ProofOfWork.generateNonce() }; - this.currentMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); - this.initialMaximumHashValueAsBigInt = BigInt(`0x${initialMaximumHashValue}`); + this.currentMaximumAllowedHashValueAsBigInt = BigInt(`0x${initialMaximumAllowedHashValue}`); + this.initialMaximumAllowedHashValueAsBigInt = BigInt(`0x${initialMaximumAllowedHashValue}`); this.desiredSolveCountPerMinute = desiredSolveCountPerMinute; this.difficultyIncreaseMultiplier = input.difficultyIncreaseMultiplier; this.challengeRefreshFrequencyInSeconds = input.challengeRefreshFrequencyInSeconds; @@ -70,21 +73,22 @@ export class ProofOfWorkManager { */ public static async create(input: { desiredSolveCountPerMinute: number, - initialMaximumHashValue: string, autoStart: boolean, + initialMaximumAllowedHashValue?: string, difficultyIncreaseMultiplier?: number, challengeRefreshFrequencyInSeconds?: number, difficultyReevaluationFrequencyInSeconds?: number }): Promise { - const { desiredSolveCountPerMinute, initialMaximumHashValue } = input; + const { desiredSolveCountPerMinute } = input; + const initialMaximumAllowedHashValue = input.initialMaximumAllowedHashValue ?? ProofOfWorkManager.defaultMaximumAllowedHashValue; const difficultyIncreaseMultiplier = input.difficultyIncreaseMultiplier ?? 1; // 1x default const challengeRefreshFrequencyInSeconds = input.challengeRefreshFrequencyInSeconds ?? 10 * 60; // 10 minutes default const difficultyReevaluationFrequencyInSeconds = input.difficultyReevaluationFrequencyInSeconds ?? 10; // 10 seconds default const proofOfWorkManager = new ProofOfWorkManager({ desiredSolveCountPerMinute, - initialMaximumHashValue, + initialMaximumAllowedHashValue, difficultyIncreaseMultiplier, challengeRefreshFrequencyInSeconds, difficultyReevaluationFrequencyInSeconds @@ -218,42 +222,43 @@ export class ProofOfWorkManager { // if solve rate is higher than desired, make difficulty harder by making the max allowed hash value smaller const currentSolveRateInFractionOfDesiredSolveRate = latestSolveCountPerMinute / this.desiredSolveCountPerMinute; - const newMaximumHashValueAsBigIntPriorToMultiplierAdjustment - = (this.currentMaximumHashValueAsBigInt * BigInt(scaleFactor)) / + const newMaximumAllowedHashValueAsBigIntPriorToMultiplierAdjustment + = (this.currentMaximumAllowedHashValueAsBigInt * BigInt(scaleFactor)) / (BigInt(Math.floor(currentSolveRateInFractionOfDesiredSolveRate * this.difficultyIncreaseMultiplier * scaleFactor))); const hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment - = (this.currentMaximumHashValueAsBigInt - newMaximumHashValueAsBigIntPriorToMultiplierAdjustment) * + = (this.currentMaximumAllowedHashValueAsBigInt - newMaximumAllowedHashValueAsBigIntPriorToMultiplierAdjustment) * (BigInt(Math.floor(this.difficultyIncreaseMultiplier * scaleFactor)) / BigInt(scaleFactor)); // Adjustment based on the reevaluation frequency to provide more-or-less consistent behavior regardless of the reevaluation frequency. const hashValueDecreaseAmount = hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment / BigInt(difficultyEvaluationsPerMinute); - this.currentMaximumHashValueAsBigInt -= hashValueDecreaseAmount; + this.currentMaximumAllowedHashValueAsBigInt -= hashValueDecreaseAmount; // Resetting to allow hash increment to be recalculated when difficulty needs to be reduced (in `else` block below) this.hashValueIncrementPerEvaluation = undefined; } else { // if solve rate is lower than desired, make difficulty easier by making the max allowed hash value larger - if (this.currentMaximumHashValueAsBigInt === this.initialMaximumHashValueAsBigInt) { + if (this.currentMaximumAllowedHashValueAsBigInt === this.initialMaximumAllowedHashValueAsBigInt) { // if current difficulty is already at initial difficulty, nothing to do return; } if (this.hashValueIncrementPerEvaluation === undefined) { const backToInitialDifficultyInMinutes = 10; - const differenceBetweenInitialAndCurrentDifficulty = this.initialMaximumHashValueAsBigInt - this.currentMaximumHashValueAsBigInt; + const differenceBetweenInitialAndCurrentDifficulty + = this.initialMaximumAllowedHashValueAsBigInt - this.currentMaximumAllowedHashValueAsBigInt; this.hashValueIncrementPerEvaluation = differenceBetweenInitialAndCurrentDifficulty / BigInt(backToInitialDifficultyInMinutes * difficultyEvaluationsPerMinute); } // if newly calculated difficulty is lower than initial difficulty, just use the initial difficulty - const newMaximumAllowedHashValueAsBigInt = this.currentMaximumHashValueAsBigInt + this.hashValueIncrementPerEvaluation; - if (newMaximumAllowedHashValueAsBigInt >= this.initialMaximumHashValueAsBigInt) { - this.currentMaximumHashValueAsBigInt = this.initialMaximumHashValueAsBigInt; + const newMaximumAllowedHashValueAsBigInt = this.currentMaximumAllowedHashValueAsBigInt + this.hashValueIncrementPerEvaluation; + if (newMaximumAllowedHashValueAsBigInt >= this.initialMaximumAllowedHashValueAsBigInt) { + this.currentMaximumAllowedHashValueAsBigInt = this.initialMaximumAllowedHashValueAsBigInt; } else { - this.currentMaximumHashValueAsBigInt = newMaximumAllowedHashValueAsBigInt; + this.currentMaximumAllowedHashValueAsBigInt = newMaximumAllowedHashValueAsBigInt; } } } diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts index 19662c0..ad7e893 100644 --- a/src/registration/registration-manager.ts +++ b/src/registration/registration-manager.ts @@ -54,8 +54,9 @@ export class RegistrationManager implements TenantGate { public static async create(input: { registrationStoreUrl: string, termsOfServiceFilePath?: string + initialMaximumAllowedHashValue?: string, }): Promise { - const { termsOfServiceFilePath, registrationStoreUrl } = input; + const { termsOfServiceFilePath, registrationStoreUrl, initialMaximumAllowedHashValue } = input; const registrationManager = new RegistrationManager(termsOfServiceFilePath); @@ -63,7 +64,7 @@ export class RegistrationManager implements TenantGate { registrationManager.proofOfWorkManager = await ProofOfWorkManager.create({ autoStart: true, desiredSolveCountPerMinute: 10, - initialMaximumHashValue: '00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + initialMaximumAllowedHashValue, }); // Initialize RegistrationStore. diff --git a/tests/dwn-server.spec.ts b/tests/dwn-server.spec.ts index b3e5e7a..ab00f4e 100644 --- a/tests/dwn-server.spec.ts +++ b/tests/dwn-server.spec.ts @@ -2,17 +2,17 @@ import { expect } from 'chai'; import { config } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { getTestDwn } from './test-dwn.js'; describe('DwnServer', function () { let dwnServer: DwnServer; before(async function () { - const testDwn = await getTestDwn(); - dwnServer = new DwnServer({ dwn: testDwn, config: config }); + dwnServer = new DwnServer({ config: config }); }); + after(async function () { dwnServer.stop(() => console.log('server stop')); }); + it('should create an instance of DwnServer', function () { expect(dwnServer).to.be.an.instanceOf(DwnServer); }); @@ -24,6 +24,7 @@ describe('DwnServer', function () { }); expect(response.status).to.equal(200); }); + it('should stop the server', async function () { dwnServer.stop(() => console.log('server Stop')); // Add an assertion to check that the server has been stopped diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 0afeaa4..b946d0d 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -54,11 +54,13 @@ describe('http api', function () { config.registrationStoreUrl = 'sqlite://'; config.registrationProofOfWorkEnabled = true; config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; + config.registrationProofOfWorkInitialMaxHash = '0FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; // 1 in 16 chance of solving // RegistrationManager creation const registrationStoreUrl = config.registrationStoreUrl; const termsOfServiceFilePath = config.termsOfServiceFilePath; - registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath }); + const initialMaximumAllowedHashValue = config.registrationProofOfWorkInitialMaxHash; + registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath, initialMaximumAllowedHashValue }); 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 081c04f..58fd481 100644 --- a/tests/registration/proof-of-work-manager.spec.ts +++ b/tests/registration/proof-of-work-manager.spec.ts @@ -26,11 +26,11 @@ describe('ProofOfWorkManager', function () { it('should periodically refresh the challenge nonce and proof-of-work difficulty', async function () { const desiredSolveCountPerMinute = 10; - const initialMaximumHashValue = 'FFFFFFFF'; + const initialMaximumAllowedHashValue = 'FFFFFFFF'; const proofOfWorkManager = await ProofOfWorkManager.create({ autoStart: true, desiredSolveCountPerMinute, - initialMaximumHashValue, + initialMaximumAllowedHashValue, }); // stub that throws half the time @@ -62,11 +62,11 @@ describe('ProofOfWorkManager', function () { 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 initialMaximumHashValue = 'FFFFFFFF'; + const initialMaximumAllowedHashValue = 'FFFFFFFF'; const proofOfWorkManager = await ProofOfWorkManager.create({ autoStart: true, desiredSolveCountPerMinute, - initialMaximumHashValue, + initialMaximumAllowedHashValue, }); // Load up desiredSolveRatePerMinute number of proof-of-work entries, so all future new entries will increase the complexity. @@ -74,8 +74,8 @@ describe('ProofOfWorkManager', function () { await proofOfWorkManager.recordProofOfWork(uuidv4()); } - let initialMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; - let lastMaximumAllowedHashValue = BigInt('0x' + initialMaximumHashValue); + let baselineMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + let lastMaximumAllowedHashValue = BigInt('0x' + initialMaximumAllowedHashValue); const lastSolveCountPerMinute = 0; for (let i = 0; i < 100; i++) { // Simulating 1 proof-of-work per second which for 100 seconds. @@ -87,7 +87,7 @@ describe('ProofOfWorkManager', function () { expect(proofOfWorkManager.currentMaximumAllowedHashValue <= lastMaximumAllowedHashValue).to.be.true; lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; } - expect(proofOfWorkManager.currentMaximumAllowedHashValue < initialMaximumAllowedHashValue).to.be.true; + expect(proofOfWorkManager.currentMaximumAllowedHashValue < baselineMaximumAllowedHashValue).to.be.true; // Simulated 100 seconds has passed, so all proof-of-work entries should be removed. clock.tick(100_000); @@ -95,7 +95,7 @@ describe('ProofOfWorkManager', function () { expect(proofOfWorkManager.currentSolveCountPerMinute).to.equal(0); - initialMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + baselineMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; for (let i = 0; i < 100; i++) { // Simulating no proof-of-work load for 100 seconds. clock.tick(1000); @@ -104,17 +104,17 @@ describe('ProofOfWorkManager', function () { expect(proofOfWorkManager.currentMaximumAllowedHashValue >= lastMaximumAllowedHashValue).to.be.true; lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; } - expect(proofOfWorkManager.currentMaximumAllowedHashValue > initialMaximumAllowedHashValue).to.be.true; + expect(proofOfWorkManager.currentMaximumAllowedHashValue > baselineMaximumAllowedHashValue).to.be.true; }); it('should reduce difficulty back to initial difficulty when proof-of-work rate is lower than desired rate for long enough', async function () { const desiredSolveCountPerMinute = 10; - const initialMaximumHashValue = 'FFFFFFFF'; - const initialMaximumHashValueAsBigInt = BigInt('0x' + initialMaximumHashValue); + const initialMaximumAllowedHashValue = 'FFFFFFFF'; + const initialMaximumAllowedHashValueAsBigInt = BigInt('0x' + initialMaximumAllowedHashValue); const proofOfWorkManager = await ProofOfWorkManager.create({ autoStart: true, desiredSolveCountPerMinute, - initialMaximumHashValue, + initialMaximumAllowedHashValue, }); // Load up desiredSolveRatePerMinute number of proof-of-work entries, so all future new entries will increase the complexity. @@ -127,12 +127,12 @@ describe('ProofOfWorkManager', function () { await proofOfWorkManager.recordProofOfWork(uuidv4()); clock.tick(1000); } - expect(proofOfWorkManager.currentMaximumAllowedHashValue < initialMaximumHashValueAsBigInt).to.be.true; + expect(proofOfWorkManager.currentMaximumAllowedHashValue < initialMaximumAllowedHashValueAsBigInt).to.be.true; // Simulated 1 hour has passed. clock.tick(60 * 60 * 1000); clock.runToLast(); - expect(proofOfWorkManager.currentMaximumAllowedHashValue === initialMaximumHashValueAsBigInt).to.be.true; + expect(proofOfWorkManager.currentMaximumAllowedHashValue === initialMaximumAllowedHashValueAsBigInt).to.be.true; }); }); diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts index e1d7aef..66ac17e 100644 --- a/tests/scenarios/registration.spec.ts +++ b/tests/scenarios/registration.spec.ts @@ -57,11 +57,13 @@ describe('Registration scenarios', function () { config.registrationStoreUrl = 'sqlite://'; config.registrationProofOfWorkEnabled = true; config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; + config.registrationProofOfWorkInitialMaxHash = '0FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; // 1 in 16 chance of solving // RegistrationManager creation const registrationStoreUrl = config.registrationStoreUrl; const termsOfServiceFilePath = config.termsOfServiceFilePath; - registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath }); + const initialMaximumAllowedHashValue = config.registrationProofOfWorkInitialMaxHash; + registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath, initialMaximumAllowedHashValue }); dwn = await getTestDwn(registrationManager); @@ -179,8 +181,8 @@ describe('Registration scenarios', function () { const { challengeNonce } = registrationManager.getProofOfWorkChallenge(); // Force the difficulty to be practically impossible. - const originalMaximumAllowedHashValueAsBigInt = registrationManager['proofOfWorkManager']['currentMaximumHashValueAsBigInt']; // for restoring later below - registrationManager['proofOfWorkManager']['currentMaximumHashValueAsBigInt'] = BigInt('0x0000000000000000000000000000000000000000000000000000000000000001'); + const originalMaximumAllowedHashValueAsBigInt = registrationManager['proofOfWorkManager']['currentMaximumAllowedHashValueAsBigInt']; // for restoring later below + registrationManager['proofOfWorkManager']['currentMaximumAllowedHashValueAsBigInt'] = BigInt('0x0000000000000000000000000000000000000000000000000000000000000001'); const registrationData: RegistrationData = { did: alice.did, @@ -194,9 +196,6 @@ describe('Registration scenarios', function () { requestData: JSON.stringify(registrationData), }); - // Restoring original difficulty for subsequent tests. - registrationManager['proofOfWorkManager']['currentMaximumHashValueAsBigInt'] = originalMaximumAllowedHashValueAsBigInt; - // 2. Alice sends the registration request to the server and is rejected. const registrationRequest: RegistrationRequest = { registrationData, @@ -214,6 +213,9 @@ describe('Registration scenarios', function () { const registrationResponseBody = await registrationResponse.json() as any; expect(registrationResponse.status).to.equal(400); expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.ProofOfWorkInsufficientSolutionNonce); + + // Restoring original difficulty for subsequent tests. + registrationManager['proofOfWorkManager']['currentMaximumAllowedHashValueAsBigInt'] = originalMaximumAllowedHashValueAsBigInt; }); it('should reject a registration request that uses an invalid/outdated terms-of-service hash', async () => { From 7fcb9d356969a8632c9aa0ad891be2b55cca054a Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 11 Jan 2024 14:50:38 -0800 Subject: [PATCH 24/27] Fixed SQL-lite directory does not exist error --- src/storage.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/storage.ts b/src/storage.ts index d8c8642..1b051f9 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; + import { DataStoreLevel, EventLogLevel, @@ -128,7 +130,13 @@ export function getDialectFromURI(u: URL): Dialect { switch (u.protocol.slice(0, -1)) { case BackendTypes.SQLITE: const path = u.host + u.pathname; - console.log('Relative SQL-lite path:', path); + console.log('SQL-lite relative path:', path ? path : undefined); // NOTE, using ? for lose equality comparison + + if (u.host && !fs.existsSync(u.host)) { + console.log('SQL-lite directory does not exist, creating:', u.host); + fs.mkdirSync(u.host, { recursive: true }); + } + return new SqliteDialect({ database: async () => new Database(path), }); From 2e38253feb1a8cb75880daa4e5360e3bf3d0dae6 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 11 Jan 2024 15:03:31 -0800 Subject: [PATCH 25/27] Added TODO for issue #101 --- src/registration/proof-of-work-manager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts index c7fbed3..14554b5 100644 --- a/src/registration/proof-of-work-manager.ts +++ b/src/registration/proof-of-work-manager.ts @@ -14,6 +14,7 @@ export class ProofOfWorkManager { private challengeNonces: { currentChallengeNonce: string, previousChallengeNonce?: 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 private difficultyIncreaseMultiplier: number; From a9333603ef2466db70325d267f0917f24730536f Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Tue, 16 Jan 2024 14:35:42 -0800 Subject: [PATCH 26/27] Addressed review comments --- package-lock.json | 8 +-- package.json | 2 +- src/json-rpc-handlers/dwn/process-message.ts | 2 +- src/registration/registration-manager.ts | 16 +++-- tests/http-api.spec.ts | 1 - .../proof-of-work-manager.spec.ts | 7 +- tests/scenarios/registration.spec.ts | 65 ++++++++++++++++++- 7 files changed, 82 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07a1379..b42c2b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@web5/dwn-server", "version": "0.1.9", "dependencies": { - "@tbd54566975/dwn-sdk-js": "0.2.11", + "@tbd54566975/dwn-sdk-js": "0.2.12", "@tbd54566975/dwn-sql-store": "0.2.6", "better-sqlite3": "^8.5.0", "body-parser": "^1.20.2", @@ -569,9 +569,9 @@ "dev": true }, "node_modules/@tbd54566975/dwn-sdk-js": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.2.11.tgz", - "integrity": "sha512-trFMhmCsyjCcrCFsaRuzpTPYyRQWVmi+5AD2mgWVwMz7hMC98dzXTVftDx3dZNZ3CwQlJe+4ts9JhVys+4q4Sg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.2.12.tgz", + "integrity": "sha512-Y1ENGZcaHyqc+NG+EXFAN7k/zRqJ5JuBr9cbkpupqUuhwN9Xjdej8uuoGviHx/72jhpx9dKfcCBeHztiTMMZOg==", "dependencies": { "@ipld/dag-cbor": "9.0.3", "@js-temporal/polyfill": "0.4.4", diff --git a/package.json b/package.json index 7402b13..f941729 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "url": "https://github.com/TBD54566975/dwn-server/issues" }, "dependencies": { - "@tbd54566975/dwn-sdk-js": "0.2.11", + "@tbd54566975/dwn-sdk-js": "0.2.12", "@tbd54566975/dwn-sql-store": "0.2.6", "better-sqlite3": "^8.5.0", "body-parser": "^1.20.2", diff --git a/src/json-rpc-handlers/dwn/process-message.ts b/src/json-rpc-handlers/dwn/process-message.ts index 30597e3..09be116 100644 --- a/src/json-rpc-handlers/dwn/process-message.ts +++ b/src/json-rpc-handlers/dwn/process-message.ts @@ -25,7 +25,7 @@ export const handleDwnProcessMessage: JsonRpcHandler = async ( const reply = (await dwn.processMessage( target, message, - dataStream as IsomorphicReadable, + { dataStream: dataStream as IsomorphicReadable }, )) as RecordsReadReply; // RecordsRead messages return record data as a stream to for accommodate large amounts of data diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts index ad7e893..220af58 100644 --- a/src/registration/registration-manager.ts +++ b/src/registration/registration-manager.ts @@ -4,7 +4,7 @@ import { RegistrationStore } from "./registration-store.js"; import type { RegistrationData, RegistrationRequest } from "./registration-types.js"; import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; -import type { TenantGate } from "@tbd54566975/dwn-sdk-js"; +import type { ActiveTenantCheckResult, TenantGate } from "@tbd54566975/dwn-sdk-js"; import { getDialectFromURI } from "../storage.js"; import { readFileSync } from "fs"; @@ -117,17 +117,23 @@ export class RegistrationManager implements TenantGate { /** * The TenantGate implementation. */ - public async isActiveTenant(tenant: string): Promise { + public async isActiveTenant(tenant: string): Promise { const tenantRegistration = await this.registrationStore.getTenantRegistration(tenant); if (tenantRegistration === undefined) { - return false + return { + isActiveTenant: false, + detail: 'Not a registered tenant.' + }; } if (tenantRegistration.termsOfServiceHash !== this.termsOfServiceHash) { - return false; + return { + isActiveTenant: false, + detail: 'Agreed terms-of-service is outdated.' + }; } - return true; + return { isActiveTenant: true } } } diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index b946d0d..0656704 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -284,7 +284,6 @@ describe('http api', function () { expect(body.error).to.not.exist; const { reply } = body.result; - console.log(reply); expect(reply.status.code).to.equal(202); }); diff --git a/tests/registration/proof-of-work-manager.spec.ts b/tests/registration/proof-of-work-manager.spec.ts index 58fd481..70e87e9 100644 --- a/tests/registration/proof-of-work-manager.spec.ts +++ b/tests/registration/proof-of-work-manager.spec.ts @@ -24,7 +24,7 @@ describe('ProofOfWorkManager', function () { clock.restore(); }); - it('should periodically refresh the challenge nonce and proof-of-work difficulty', async function () { + it('should continue to periodically refresh the challenge nonce and proof-of-work difficulty even if the refresh logic throws error.', async function () { const desiredSolveCountPerMinute = 10; const initialMaximumAllowedHashValue = 'FFFFFFFF'; const proofOfWorkManager = await ProofOfWorkManager.create({ @@ -47,7 +47,6 @@ describe('ProofOfWorkManager', function () { const challengeNonceRefreshSpy = sinon.stub(proofOfWorkManager, 'refreshChallengeNonce').callsFake(stub); const maximumAllowedHashValueRefreshSpy = sinon.stub(proofOfWorkManager, 'refreshMaximumAllowedHashValue').callsFake(stub); - // Simulated 1 hour has passed, so all proof-of-work entries should be removed. clock.tick(60 * 60 * 1000); // 1 hour divided by the challenge refresh frequency @@ -78,7 +77,7 @@ describe('ProofOfWorkManager', function () { let lastMaximumAllowedHashValue = BigInt('0x' + initialMaximumAllowedHashValue); const lastSolveCountPerMinute = 0; for (let i = 0; i < 100; i++) { - // Simulating 1 proof-of-work per second which for 100 seconds. + // Simulating 1 proof-of-work per second for 100 seconds. await proofOfWorkManager.recordProofOfWork(uuidv4()); expect(proofOfWorkManager.currentSolveCountPerMinute).to.be.greaterThanOrEqual(lastSolveCountPerMinute); clock.tick(1000); @@ -122,7 +121,7 @@ describe('ProofOfWorkManager', function () { await proofOfWorkManager.recordProofOfWork(uuidv4()); } - // Simulating 1 proof-of-work per second which for 100 seconds to increase proof-of-work difficulty. + // Simulating 1 proof-of-work per second for 100 seconds to increase proof-of-work difficulty. for (let i = 0; i < 100; i++) { await proofOfWorkManager.recordProofOfWork(uuidv4()); clock.tick(1000); diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts index 66ac17e..39d44db 100644 --- a/tests/scenarios/registration.spec.ts +++ b/tests/scenarios/registration.spec.ts @@ -168,6 +168,7 @@ describe('Registration scenarios', function () { const nonTenantJsonRpcResponseBody = await nonTenantJsonRpcResponse.json() as JsonRpcResponse; expect(nonTenantJsonRpcResponse.status).to.equal(200); expect(nonTenantJsonRpcResponseBody.result.reply.status.code).to.equal(401); + expect(nonTenantJsonRpcResponseBody.result.reply.status.detail).to.equal('Not a registered tenant.'); }); it('should reject a registration request that has proof-or-work that does not meet the difficulty requirement.', async function () { @@ -258,7 +259,6 @@ describe('Registration scenarios', function () { expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.RegistrationManagerInvalidOrOutdatedTermsOfServiceHash); }); - it('should reject registration request that reuses a response nonce that is already used a short-time earlier', async () => { // Scenario: // 0. Assume Alice fetched the proof-of-work challenge and the terms-of-service. @@ -402,9 +402,12 @@ describe('Registration scenarios', function () { // 1. Alice is a registered tenant and is able to write to the DWN. // 2. DWN server administrator updates the terms-of-service. // 3. Alice no longer can write to the DWN because she has not agreed to the new terms-of-service. + // 4. Alice fetches the new terms-of-service and proof-of-work challenge + // 5. Alice agrees to the new terms-of-service. + // 6. Alice can now write to the DWN again. // 1. Alice is a registered tenant and is able to write to the DWN. - // Short-cut to register Alice. + // Shortcut to register Alice. registrationManager.recordTenantRegistration({ did: alice.did, termsOfServiceHash: ProofOfWork.hashAsHexString([registrationManager.getTermsOfService()]) @@ -424,7 +427,8 @@ describe('Registration scenarios', function () { expect(write1ResponseBody.result.reply.status.code).to.equal(202); // 2. DWN server administrator updates the terms-of-service. - registrationManager.updateTermsOfService('new terms of service'); + const newTermsOfService = 'new terms of service'; + registrationManager.updateTermsOfService(newTermsOfService); // 3. Alice no longer can write to the DWN because she has not agreed to the new terms-of-service. const write2 = await generateRecordsWriteJsonRpcRequest(alice); @@ -438,6 +442,61 @@ describe('Registration scenarios', function () { const write2ResponseBody = await write2Response.json() as JsonRpcResponse; expect(write2Response.status).to.equal(200); expect(write2ResponseBody.result.reply.status.code).to.equal(401); + expect(write2ResponseBody.result.reply.status.detail).to.equal('Agreed terms-of-service is outdated.'); + + // 4. Alice fetches the new terms-of-service and proof-of-work challenge + const termsOfServiceGetResponse = await fetch(termsOfUseEndpoint, { + method: 'GET', + }); + const termsOfServiceFetched = await termsOfServiceGetResponse.text(); + expect(termsOfServiceGetResponse.status).to.equal(200); + expect(termsOfServiceFetched).to.equal(newTermsOfService); + + const proofOfWorkChallengeGetResponse = await fetch(proofOfWorkEndpoint, { + method: 'GET', + }); + const { challengeNonce, maximumAllowedHashValue} = await proofOfWorkChallengeGetResponse.json() as ProofOfWorkChallengeModel; + + // 5. Alice agrees to the new terms-of-service. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([newTermsOfService]), + }; + + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + expect(registrationResponse.status).to.equal(200); + + // 6. Alice can now write to the DWN again. + const { jsonRpcRequest, dataBytes } = await generateRecordsWriteJsonRpcRequest(alice); + const write3Response = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(jsonRpcRequest), + }, + body: new Blob([dataBytes]), + }); + const write3ResponseBody = await write3Response.json() as JsonRpcResponse; + expect(write3Response.status).to.equal(200); + expect(write3ResponseBody.result.reply.status.code).to.equal(202); + }); }); From b36d176ab19c42f84443c812b29cb81233b8d9ef Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Tue, 16 Jan 2024 15:05:39 -0800 Subject: [PATCH 27/27] Added comment --- src/registration/proof-of-work.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/registration/proof-of-work.ts b/src/registration/proof-of-work.ts index a1b53a1..5d7d691 100644 --- a/src/registration/proof-of-work.ts +++ b/src/registration/proof-of-work.ts @@ -52,6 +52,8 @@ export class ProofOfWork { /** * Finds a response nonce that qualifies the difficulty requirement for the given proof-of-work challenge and request data. + * NOTE: mainly for demonstrating the procedure to find a qualified response nonce. + * Will need to artificially introduce asynchrony to allow other tasks to run if this method is to be used in a real-world client. */ public static findQualifiedResponseNonce(input: { maximumAllowedHashValue: string;