From aa6ae54bc25c0824a799155e56324b62009b2157 Mon Sep 17 00:00:00 2001 From: finn Date: Thu, 16 Nov 2023 14:20:19 -0800 Subject: [PATCH] add TOS checking to tenant gate --- src/config.ts | 2 + src/dwn-server.ts | 29 ++++-- src/http-api.ts | 13 +-- src/pow.ts | 136 -------------------------- src/storage.ts | 8 +- src/tenant-gate.ts | 213 +++++++++++++++++++++++++++++++++++++++++ src/ws-api.ts | 17 +--- tests/http-api.spec.ts | 18 ++-- tests/test-dwn.ts | 20 ++++ tests/ws-api.spec.ts | 8 +- 10 files changed, 278 insertions(+), 186 deletions(-) delete mode 100644 src/pow.ts create mode 100644 src/tenant-gate.ts diff --git a/src/config.ts b/src/config.ts index e56ee32..fca0af9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,8 @@ export const config = { process.env.DWN_STORAGE || 'sqlite://data/dwn.db', + tos: process.env.DWN_TOS_FILE, + // 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 ea09237..a5522c4 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'; @@ -9,9 +10,9 @@ 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 { ProofOfWork } from './pow.js'; import { setProcessHandlers } from './process-handlers.js'; import { getDWNConfig, getDialectFromURI } from './storage.js'; +import { TenantGate } from './tenant-gate.js'; import { WsApi } from './ws-api.js'; export type DwnServerOptions = { @@ -47,18 +48,26 @@ 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)); - } - - let pow: ProofOfWork = null; - if (this.config.powRegistration) { - pow = new ProofOfWork( - getDialectFromURI(new URL(this.config.tenantRegistrationStore)), + const tenantGateDB = getDialectFromURI( + new URL(this.config.tenantRegistrationStore), ); + const tos = + this.config.tos !== undefined + ? readFileSync(this.config.tos).toString() + : null; + tenantGate = new TenantGate( + tenantGateDB, + this.config.powRegistration, + this.config.tos !== undefined, + tos, + ); + + this.dwn = await Dwn.create(getDWNConfig(this.config, tenantGate)); } - this.#httpApi = new HttpApi(this.dwn, pow); + this.#httpApi = new HttpApi(this.dwn, tenantGate); await this.#httpApi.start(this.config.port, () => { log.info(`HttpServer listening on port ${this.config.port}`); }); @@ -68,7 +77,7 @@ export class DwnServer { ); if (this.config.webSocketServerEnabled) { - this.#wsApi = new WsApi(this.#httpApi.server, this.dwn, pow); + this.#wsApi = new WsApi(this.#httpApi.server, this.dwn); this.#wsApi.start(() => log.info(`WebSocketServer ready...`)); } } diff --git a/src/http-api.ts b/src/http-api.ts index a362bf1..107e105 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -24,7 +24,7 @@ import { import { config } from './config.js'; import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter, responseHistogram } from './metrics.js'; -import type { ProofOfWork } from './pow.js'; +import type { TenantGate } from './tenant-gate.js'; const packagejson = process.env.npm_package_json ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) @@ -33,10 +33,10 @@ const packagejson = process.env.npm_package_json export class HttpApi { #api: Express; #server: http.Server; - pow?: ProofOfWork; + pow?: TenantGate; dwn: Dwn; - constructor(dwn: Dwn, pow?: ProofOfWork) { + constructor(dwn: Dwn, pow?: TenantGate) { this.#api = express(); this.#server = http.createServer(this.#api); this.dwn = dwn; @@ -91,7 +91,7 @@ export class HttpApi { }); this.#api.get('/:did/records/:id', async (req, res) => { - if (this.pow && !(await this.pow.isAuthorized(req.params.did))) { + if (this.pow && !(await this.pow.isTenant(req.params.did))) { return res.status(403).json('did not authorized on this server'); } @@ -156,10 +156,7 @@ export class HttpApi { return res.status(400).json(reply); } - if ( - this.pow && - !(await this.pow.isAuthorized(dwnRpcRequest.params.target)) - ) { + if (this.pow && !(await this.pow.isTenant(dwnRpcRequest.params.target))) { const reply = createJsonRpcErrorResponse( dwnRpcRequest.id || uuidv4(), JsonRpcErrorCodes.Forbidden, diff --git a/src/pow.ts b/src/pow.ts deleted file mode 100644 index b2f7677..0000000 --- a/src/pow.ts +++ /dev/null @@ -1,136 +0,0 @@ -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 = 60 * 1000; -const COMPLEXITY_LOOKBACK = 60 * 1000; // complexity is based on number of successful registrations in this timeframe -const COMPLEXITY_MINIMUM = 5; - -export class ProofOfWork { - #db: Kysely; - - constructor(dialect: Dialect) { - this.#db = new Kysely({ dialect: dialect }); - } - - 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('timeadded', 'timestamp', (column) => column.notNull()) - .execute(); - } - - setupRoutes(server: Express): void { - server.get('/register/pow', (req: Request, res: Response) => - this.getChallenge(req, res), - ); - server.post('/register/pow', (req: Request, res: Response) => - this.verifyChallenge(req, res), - ); - } - - async isAuthorized(tenant: string): Promise { - const result = await this.#db - .selectFrom('authorizedTenants') - .select('did') - .where('did', '=', tenant) - .execute(); - - return result.length > 0; - } - - async authorizeTenant(tenant: string): Promise { - await this.#db - .insertInto('authorizedTenants') - .values({ did: tenant, timeadded: Date.now() }) - .executeTakeFirst(); - } - - private async getChallenge(_req: Request, res: Response): Promise { - const challenge = generateChallenge(); - recentChallenges[challenge] = Date.now(); - res.json({ - challenge: challenge, - complexity: await this.getComplexity(), - }); - } - - private async verifyChallenge(req: Request, res: Response): Promise { - const body: { - did: string; - challenge: string; - response: string; - } = req.body; - - 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 }); - return; - } - - try { - await this.#db - .insertInto('authorizedTenants') - .values({ did: body.did, timeadded: Date.now() }) - .executeTakeFirst(); - } 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('timeadded', '>', 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; - } -} - -function generateChallenge(): string { - return randomBytes(10).toString('base64'); -} - -interface AuthorizedTenants { - did: string; - timeadded: number; -} - -interface PowDatabase { - authorizedTenants: AuthorizedTenants; -} diff --git a/src/storage.ts b/src/storage.ts index 0bcac2c..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( diff --git a/src/tenant-gate.ts b/src/tenant-gate.ts new file mode 100644 index 0000000..8e4dffa --- /dev/null +++ b/src/tenant-gate.ts @@ -0,0 +1,213 @@ +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; + + constructor( + dialect: Dialect, + powRequired: boolean, + tosRequired: boolean, + currentTOS?: 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'); + } + } + + 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 { + const result = await this.#db + .selectFrom('authorizedTenants') + .select('powTime') + .select('tos') + .where('did', '=', tenant) + .execute(); + + if (result.length == 0) { + return false; + } + + const row = result[0]; + + if (this.#powRequired && row.powTime == undefined) { + return false; + } + + if (this.#tosRequired && row.tos != this.#tosHash) { + return false; + } + + return true; + } + + async authorizeTenant(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 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 }); + return; + } + + try { + await this.authorizeTenant(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({ + error: 'incorrect TOS hash', + }); + } + + await this.#db + .insertInto('authorizedTenants') + .values({ + did: body.did, + tos: body.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/src/ws-api.ts b/src/ws-api.ts index 7e1edb9..cac1cea 100644 --- a/src/ws-api.ts +++ b/src/ws-api.ts @@ -14,19 +14,16 @@ import { import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter } from './metrics.js'; -import type { ProofOfWork } from './pow.js'; const SOCKET_ISALIVE_SYMBOL = Symbol('isAlive'); const HEARTBEAT_INTERVAL = 30_000; export class WsApi { #wsServer: WebSocketServer; - pow?: ProofOfWork; dwn: Dwn; - constructor(server: Server, dwn: Dwn, pow?: ProofOfWork) { + constructor(server: Server, dwn: Dwn) { this.dwn = dwn; - this.pow = pow; this.#wsServer = new WebSocketServer({ server }); } @@ -44,7 +41,6 @@ export class WsApi { */ #handleConnection(socket: WebSocket, _request: IncomingMessage): void { const dwn = this.dwn; - const pow = this.pow; socket[SOCKET_ISALIVE_SYMBOL] = true; @@ -95,17 +91,6 @@ export class WsApi { return socket.send(responseBuffer); } - if (pow && !(await pow.isAuthorized(dwnRequest.params.target))) { - const jsonRpcResponse = createJsonRpcErrorResponse( - dwnRequest.id || uuidv4(), - JsonRpcErrorCodes.Forbidden, - 'tenant not authorized, please register first', - ); - - const responseBuffer = WsApi.jsonRpcResponseToBuffer(jsonRpcResponse); - return socket.send(responseBuffer); - } - // Check whether data was provided in the request const { encodedData } = dwnRequest.params; const requestDataStream = encodedData diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index c570120..cf0e572 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -5,6 +5,7 @@ import { RecordsQuery, RecordsRead, } from '@tbd54566975/dwn-sdk-js'; +import type { Dwn } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; import type { Server } from 'http'; @@ -23,9 +24,8 @@ import { createJsonRpcRequest, JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; -import { ProofOfWork } from '../src/pow.js'; -import { getDialectFromURI } from '../src/storage.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import type { TenantGate } from '../src/tenant-gate.js'; +import { clear as clearDwn, getDwn as getTestDwn } from './test-dwn.js'; import type { Profile } from './utils.js'; import { createProfile, @@ -45,13 +45,17 @@ describe('http api', function () { let httpApi: HttpApi; let server: Server; let profile: Profile; - let pow: ProofOfWork; + let tenantGate: TenantGate; + let dwn: Dwn; before(async function () { config.powRegistration = true; - pow = new ProofOfWork(getDialectFromURI(new URL('sqlite://'))); + const testdwn = await getTestDwn(); + dwn = testdwn.dwn; + tenantGate = testdwn.tenantGate; + profile = await createProfile(); - httpApi = new HttpApi(dwn, pow); + httpApi = new HttpApi(dwn, tenantGate); }); beforeEach(async function () { @@ -111,7 +115,7 @@ describe('http api', function () { for (let i = 1; i <= 60; i++) { const p = await createProfile(); if (i < 59) { - pow.authorizeTenant(p.did); + tenantGate.authorizeTenant(p.did); continue; } diff --git a/tests/test-dwn.ts b/tests/test-dwn.ts index 515ece5..97cf054 100644 --- a/tests/test-dwn.ts +++ b/tests/test-dwn.ts @@ -4,6 +4,14 @@ import { EventLogLevel, MessageStoreLevel, } from '@tbd54566975/dwn-sdk-js'; +import { + DataStoreSql, + EventLogSql, + MessageStoreSql, +} from '@tbd54566975/dwn-sql-store'; + +import { getDialectFromURI } from '../src/storage.js'; +import { TenantGate } from '../src/tenant-gate.js'; const dataStore = new DataStoreLevel({ blockstoreLocation: 'data/DATASTORE' }); const eventLog = new EventLogLevel({ location: 'data/EVENTLOG' }); @@ -19,3 +27,15 @@ export async function clear(): Promise { await eventLog.clear(); await messageStore.clear(); } + +export async function getDwn(): 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, true, false); + + const dwn = await Dwn.create({ eventLog, dataStore, messageStore }); + + return { dwn, tenantGate }; +} diff --git a/tests/ws-api.spec.ts b/tests/ws-api.spec.ts index bd3986f..775141b 100644 --- a/tests/ws-api.spec.ts +++ b/tests/ws-api.spec.ts @@ -10,8 +10,6 @@ import { createJsonRpcRequest, JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; -import { ProofOfWork } from '../src/pow.js'; -import { getDialectFromURI } from '../src/storage.js'; import { WsApi } from '../src/ws-api.js'; import { clear as clearDwn, dwn } from './test-dwn.js'; import { @@ -22,17 +20,14 @@ import { let server: http.Server; let wsServer: WebSocketServer; -let pow: ProofOfWork; describe('websocket api', function () { before(async function () { server = http.createServer(); server.listen(9002, '127.0.0.1'); - pow = new ProofOfWork(getDialectFromURI(new URL('sqlite://'))); - const wsApi = new WsApi(server, dwn, pow); + const wsApi = new WsApi(server, dwn); wsServer = wsApi.start(); - await pow.initialize(); }); afterEach(async function () { @@ -66,7 +61,6 @@ describe('websocket api', function () { it('handles RecordsWrite messages', async function () { const alice = await createProfile(); - pow.authorizeTenant(alice.did); const { recordsWrite, dataStream } = await createRecordsWriteMessage(alice); const dataBytes = await DataStream.toBytes(dataStream);