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..c5190c6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,13 @@ export const config = { eventLog: process.env.DWN_STORAGE_EVENTS || process.env.DWN_STORAGE || 'level://data', + // require POW-based registration for new tenants + powRegistration: process.env.DWN_REGISTRATION_POW == 'true', + tenantRegistrationStore: + process.env.DWN_REGISTRATION_STORE || + process.env.DWN_STORAGE || + 'sqlite://data/dwn.db', + // 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..ea09237 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -9,8 +9,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 } from './storage.js'; +import { getDWNConfig, getDialectFromURI } from './storage.js'; import { WsApi } from './ws-api.js'; export type DwnServerOptions = { @@ -50,8 +51,15 @@ export class DwnServer { this.dwn = await Dwn.create(getDWNConfig(this.config)); } - this.#httpApi = new HttpApi(this.dwn); - this.#httpApi.start(this.config.port, () => { + let pow: ProofOfWork = null; + if (this.config.powRegistration) { + pow = new ProofOfWork( + getDialectFromURI(new URL(this.config.tenantRegistrationStore)), + ); + } + + this.#httpApi = new HttpApi(this.dwn, pow); + await this.#httpApi.start(this.config.port, () => { log.info(`HttpServer listening on port ${this.config.port}`); }); @@ -60,7 +68,7 @@ export class DwnServer { ); if (this.config.webSocketServerEnabled) { - this.#wsApi = new WsApi(this.#httpApi.server, this.dwn); + this.#wsApi = new WsApi(this.#httpApi.server, this.dwn, pow); this.#wsApi.start(() => log.info(`WebSocketServer ready...`)); } } diff --git a/src/http-api.ts b/src/http-api.ts index d40d90e..0373115 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -22,16 +22,19 @@ import { import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter, responseHistogram } from './metrics.js'; +import type { ProofOfWork } from './pow.js'; export class HttpApi { #api: Express; #server: http.Server; + pow?: ProofOfWork; dwn: Dwn; - constructor(dwn: Dwn) { + constructor(dwn: Dwn, pow?: ProofOfWork) { this.#api = express(); this.#server = http.createServer(this.#api); this.dwn = dwn; + this.pow = pow; this.#setupMiddleware(); this.#setupRoutes(); @@ -47,6 +50,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) => { @@ -81,6 +85,10 @@ export class HttpApi { }); this.#api.get('/:did/records/:id', async (req, res) => { + if (this.pow && !(await this.pow.isAuthorized(req.params.did))) { + return res.status(403).json('did not authorized on this server'); + } + const record = await RecordsRead.create({ filter: { recordId: req.params.id }, }); @@ -142,6 +150,19 @@ export class HttpApi { return res.status(400).json(reply); } + if ( + this.pow && + !(await this.pow.isAuthorized(dwnRpcRequest.params.target)) + ) { + const reply = createJsonRpcErrorResponse( + dwnRpcRequest.id || uuidv4(), + JsonRpcErrorCodes.Forbidden, + 'tenant not authorized, please register first', + ); + + return res.status(403).json(reply); + } + // Check whether data was provided in the request body const contentLength = req.headers['content-length']; const transferEncoding = req.headers['transfer-encoding']; @@ -181,13 +202,20 @@ export class HttpApi { return res.json(jsonRpcResponse); } }); + + if (this.pow) { + this.pow.setupRoutes(this.#api); + } } #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.pow) { + await this.pow.initialize(); + } this.#listen(port, callback); return this.#server; } diff --git a/src/pow.ts b/src/pow.ts new file mode 100644 index 0000000..d3ae511 --- /dev/null +++ b/src/pow.ts @@ -0,0 +1,119 @@ +import { createHash } 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; + +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()) + .execute(); + } + + setupRoutes(server: Express): void { + server.get('/register', (req: Request, res: Response) => + this.getChallenge(req, res), + ); + server.post('/register', (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; + } + + private async getChallenge(_req: Request, res: Response): Promise { + const challenge = generateChallenge(); + recentChallenges[challenge] = Date.now(); + res.json({ + challenge: challenge, + complexity: 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 = getComplexity(); + const digest = hash.digest('hex'); + console.log('digest: ', digest); + if (!digest.startsWith('0'.repeat(complexity))) { + res.status(401).json({ success: false }); + return; + } + + try { + await this.#db + .insertInto('authorizedTenants') + .values({ did: body.did }) + .executeTakeFirst(); + } catch (e) { + console.log('error inserting did', e); + res.status(500).json({ success: false }); + return; + } + res.json({ success: true }); + } +} + +const challengeCharacters = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +function generateChallenge(): string { + let challenge = ''; + while (challenge.length < 10) { + challenge += challengeCharacters.charAt( + Math.floor(Math.random() * challengeCharacters.length), + ); + } + return challenge; +} + +function getComplexity(): number { + return Object.keys(recentChallenges).length; +} +interface AuthorizedTenants { + did: string; +} + +interface PowDatabase { + authorizedTenants: AuthorizedTenants; +} diff --git a/src/storage.ts b/src/storage.ts index 6719bd6..0bcac2c 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -113,14 +113,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/ws-api.ts b/src/ws-api.ts index cac1cea..7e1edb9 100644 --- a/src/ws-api.ts +++ b/src/ws-api.ts @@ -14,16 +14,19 @@ 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) { + constructor(server: Server, dwn: Dwn, pow?: ProofOfWork) { this.dwn = dwn; + this.pow = pow; this.#wsServer = new WebSocketServer({ server }); } @@ -41,6 +44,7 @@ export class WsApi { */ #handleConnection(socket: WebSocket, _request: IncomingMessage): void { const dwn = this.dwn; + const pow = this.pow; socket[SOCKET_ISALIVE_SYMBOL] = true; @@ -91,6 +95,17 @@ 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 b9e878f..b14e9ae 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -7,12 +7,14 @@ import { } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; +import { createHash } from 'crypto'; import type { Server } from 'http'; import fetch from 'node-fetch'; import { webcrypto } from 'node:crypto'; 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,7 +24,10 @@ 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 { Profile } from './utils.js'; import { createProfile, createRecordsWriteMessage, @@ -38,13 +43,17 @@ if (!globalThis.crypto) { describe('http api', function () { let httpApi: HttpApi; let server: Server; + let profile: Profile; before(async function () { - httpApi = new HttpApi(dwn); + config.powRegistration = true; + const pow = new ProofOfWork(getDialectFromURI(new URL('sqlite://'))); + profile = await createProfile(); + httpApi = new HttpApi(dwn, pow); }); beforeEach(async function () { - server = httpApi.start(3000); + server = await httpApi.start(3000); }); afterEach(async function () { @@ -53,111 +62,243 @@ describe('http api', function () { await clearDwn(); }); - it('responds with a 400 if no dwn-request header is provided', async function () { - const response = await request(httpApi.api).post('/').send(); + describe('/register', function () { + it('returns a register challenge', async function () { + const response = await fetch('http://localhost:3000/register'); + expect(response.status).to.equal(200); + const body = (await response.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(10); + expect(body.complexity).to.equal(1); + }); - expect(response.statusCode).to.equal(400); + it('accepts a correct registration challenge', async function () { + const challengeResponse = await fetch('http://localhost:3000/register'); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(10); + expect(body.complexity).to.equal(2); + + // solve the challenge + let response = ''; + while (!checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + } + + const submitResponse = await fetch('http://localhost:3000/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: profile.did, + }), + }); - const body = response.body as JsonRpcErrorResponse; - expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); - expect(body.error.message).to.equal('request payload required.'); - }); + expect(submitResponse.status).to.equal(200); + }); + + it('increase complexity as more challenges are issued', async function () { + const challengeResponse = await fetch('http://localhost:3000/register'); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(10); + expect(body.complexity).to.equal(3); + }); + + it('rejects an invalid nonce', async function () { + const challengeResponse = await fetch('http://localhost:3000/register'); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(10); + + // 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 submitResponse = await fetch('http://localhost:3000/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: profile.did, + }), + }); + + expect(submitResponse.status).to.equal(401); + }); + + it('rejects a challenge it did not issue', async function () { + const challenge = generateNonce(10); + + // solve the challenge + let response = ''; + while (!checkNonce(challenge, response, 2)) { + response = generateNonce(5); + } + + const submitResponse = await fetch('http://localhost:3000/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: challenge, + response: response, + did: profile.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(401); + }); - expect(response.statusCode).to.equal(400); + it('rejects unauthorized tenants', 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 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(); + + expect(response.statusCode).to.equal(403); + expect(response.body.id).to.equal(requestId); + }); }); - 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); + 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(); - // Intentionally delete a required property to produce an invalid RecordsWrite message. - const message = recordsWrite.toJSON(); - delete message['descriptor']['interface']; + expect(response.statusCode).to.equal(400); - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: message, - target: alice.did, + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.equal('request payload required.'); }); - const dataBytes = await DataStream.toBytes(dataStream); + it('responds with a 400 if parsing dwn request fails', async function () { + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', ';;;;@!#@!$$#!@%') + .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(400); + + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.include('JSON'); }); - expect(responseInitialWrite.status).to.equal(200); + 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); - const body = (await responseInitialWrite.json()) as JsonRpcResponse; - expect(body.id).to.equal(requestId); - expect(body.error).to.not.exist; + // Intentionally delete a required property to produce an invalid RecordsWrite message. + const message = recordsWrite.toJSON(); + delete message['descriptor']['interface']; - 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 requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: message, + target: profile.did, + }); - 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. + const dataBytes = await DataStream.toBytes(dataStream); - // 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(); + // 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]), + }); - // Check if the 'access-control-expose-headers' header is present - expect(response.headers).to.have.property('access-control-expose-headers'); + expect(responseInitialWrite.status).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'); - }); + const body = (await responseInitialWrite.json()) as JsonRpcResponse; + expect(body.id).to.equal(requestId); + expect(body.error).to.not.exist; - 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, + 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 requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: alice.did, + 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'); }); - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); + 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); + 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 +306,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 +314,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 +340,14 @@ describe('http api', function () { }); it('handles RecordsWrite overwrite that does not mutate data', async function () { - const alice = await createProfile(); - // First RecordsWrite that creates the record. const { recordsWrite: initialWrite, dataStream } = - await createRecordsWriteMessage(alice); + await createRecordsWriteMessage(profile); const dataBytes = await DataStream.toBytes(dataStream); let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: initialWrite.toJSON(), - target: alice.did, + target: profile.did, }); const responseInitialWrite = await fetch('http://localhost:3000', { @@ -224,7 +362,7 @@ describe('http api', function () { // Subsequent RecordsWrite that mutates the published property of the record. const { recordsWrite: overWrite } = await createRecordsWriteMessage( - alice, + profile, { recordId: initialWrite.message.recordId, dataCid: initialWrite.message.descriptor.dataCid, @@ -237,7 +375,7 @@ describe('http api', function () { requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: overWrite.toJSON(), - target: alice.did, + target: profile.did, }); const responseOverwrite = await fetch('http://localhost:3000', { method: 'POST', @@ -258,14 +396,13 @@ describe('http api', function () { }); 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 +443,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 +451,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 +472,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 +480,7 @@ describe('http api', function () { requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - target: alice.did, + target: profile.did, message: recordsRead.toJSON(), }); @@ -390,8 +526,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 +535,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 +556,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 +571,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 +579,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 +600,61 @@ 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 403 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); + expect(response.status).to.equal(403); }); 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); }); }); }); + +const nonceChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +function generateNonce(size: number): string { + let challenge = ''; + while (challenge.length < size) { + challenge += nonceChars.charAt( + Math.floor(Math.random() * nonceChars.length), + ); + } + return challenge; +} + +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)); +}