Add built-in service (Sig) which signs data and verifies signatures (#110)

This commit is contained in:
Pavel 2021-12-10 16:47:58 +03:00 committed by GitHub
parent 25da21aeeb
commit 48fc017a1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 443 additions and 170 deletions

14
package-lock.json generated
View File

@ -12,7 +12,6 @@
"@chainsafe/libp2p-noise": "4.0.0",
"@fluencelabs/avm": "^0.17.6",
"async": "3.2.0",
"base64-js": "1.5.1",
"bs58": "4.0.1",
"cids": "0.8.1",
"it-length-prefixed": "3.0.1",
@ -31,6 +30,7 @@
"devDependencies": {
"@types/jest": "^26.0.22",
"jest": "^26.6.3",
"js-base64": "^3.7.2",
"ts-jest": "^26.5.4",
"typedoc": "^0.21.9",
"typescript": "^4.0.0"
@ -4668,6 +4668,12 @@
"node": ">= 10.14.2"
}
},
"node_modules/js-base64": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz",
"integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==",
"dev": true
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -11936,6 +11942,12 @@
"supports-color": "^7.0.0"
}
},
"js-base64": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz",
"integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==",
"dev": true
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@ -23,7 +23,6 @@
"@chainsafe/libp2p-noise": "4.0.0",
"@fluencelabs/avm": "^0.17.6",
"async": "3.2.0",
"base64-js": "1.5.1",
"bs58": "4.0.1",
"cids": "0.8.1",
"it-length-prefixed": "3.0.1",
@ -44,6 +43,7 @@
"jest": "^26.6.3",
"ts-jest": "^26.5.4",
"typedoc": "^0.21.9",
"typescript": "^4.0.0"
"typescript": "^4.0.0",
"js-base64": "^3.7.2"
}
}

View File

@ -1,41 +1,75 @@
import each from 'jest-each';
import { CallServiceData } from '../../internal/commonTypes';
import { defaultServices } from '../../internal/defaultServices';
import each from 'jest-each';
import { BuiltInServiceContext, builtInServices } from '../../internal/builtInServices';
import { KeyPair } from '../../internal/KeyPair';
import { toUint8Array } from 'js-base64';
const key = '+cmeYlZKj+MfSa9dpHV+BmLPm6wq4inGlsPlQ1GvtPk=';
const context = (async () => {
const keyBytes = toUint8Array(key);
const kp = await KeyPair.fromEd25519SK(keyBytes);
const res: BuiltInServiceContext = {
peerKeyPair: kp,
peerId: kp.Libp2pPeerId.toB58String(),
};
return res;
})();
const testData = [1, 2, 3, 4, 5, 6, 7, 9, 10];
// signature produced by KeyPair created from key above (`key` variable)
const testDataSig = [
224, 104, 245, 206, 140, 248, 27, 72, 68, 133, 111, 10, 164, 197, 242, 132, 107, 77, 224, 67, 99, 106, 76, 29, 144,
121, 122, 169, 36, 173, 58, 80, 170, 102, 137, 253, 157, 247, 168, 87, 162, 223, 188, 214, 203, 220, 52, 246, 29,
86, 77, 71, 224, 248, 16, 213, 254, 75, 78, 239, 243, 222, 241, 15,
];
// signature produced by KeyPair created from some random KeyPair
const testDataWrongSig = [
116, 247, 189, 118, 236, 53, 147, 123, 219, 75, 176, 105, 101, 108, 233, 137, 97, 14, 146, 132, 252, 70, 51, 153,
237, 167, 156, 150, 36, 90, 229, 108, 166, 231, 255, 137, 8, 246, 125, 0, 213, 150, 83, 196, 237, 221, 131, 159,
157, 159, 25, 109, 95, 160, 181, 65, 254, 238, 47, 156, 240, 151, 58, 14,
];
describe('Tests for default handler', () => {
// prettier-ignore
each`
serviceId | fnName | args | retCode | result
${'op'} | ${'identity'} | ${[]} | ${0} | ${{}}
${'op'} | ${'identity'} | ${[1]} | ${0} | ${1}
${'op'} | ${'identity'} | ${[1, 2]} | ${1} | ${'identity accepts up to 1 arguments, received 2 arguments'}
${'op'} | ${'noop'} | ${[1, 2]} | ${0} | ${{}}
${'op'} | ${'array'} | ${[1, 2, 3]} | ${0} | ${[1, 2, 3]}
${'op'} | ${'concat'} | ${[[1, 2], [3, 4], [5, 6]]} | ${0} | ${[1, 2, 3, 4, 5, 6]}
${'op'} | ${'concat'} | ${[[1, 2]]} | ${0} | ${[1, 2]}
${'op'} | ${'concat'} | ${[]} | ${0} | ${[]}
${'op'} | ${'concat'} | ${[1, [1, 2], 1]} | ${1} | ${"All arguments of 'concat' must be arrays: arguments 0, 2 are not"}
${'op'} | ${'string_to_b58'} | ${["test"]} | ${0} | ${"3yZe7d"}
${'op'} | ${'string_to_b58'} | ${["test", 1]} | ${1} | ${"string_to_b58 accepts only one string argument"}
${'op'} | ${'string_from_b58'} | ${["3yZe7d"]} | ${0} | ${"test"}
${'op'} | ${'string_from_b58'} | ${["3yZe7d", 1]} | ${1} | ${"string_from_b58 accepts only one string argument"}
${'op'} | ${'bytes_to_b58'} | ${[[116, 101, 115, 116]]} | ${0} | ${"3yZe7d"}
${'op'} | ${'bytes_to_b58'} | ${[[116, 101, 115, 116], 1]} | ${1} | ${"bytes_to_b58 accepts only single argument: array of numbers"}
${'op'} | ${'bytes_from_b58'} | ${["3yZe7d"]} | ${0} | ${[116, 101, 115, 116]}
${'op'} | ${'bytes_from_b58'} | ${["3yZe7d", 1]} | ${1} | ${"bytes_from_b58 accepts only one string argument"}
${'peer'} | ${'timeout'} | ${[200, []]} | ${0} | ${[]}}
${'peer'} | ${'timeout'} | ${[200, ['test']]} | ${0} | ${['test']}}
${'peer'} | ${'timeout'} | ${[]} | ${1} | ${'timeout accepts exactly two arguments: timeout duration in ms and a message string'}}
${'peer'} | ${'timeout'} | ${[200, 'test', 1]} | ${1} | ${'timeout accepts exactly two arguments: timeout duration in ms and a message string'}}
serviceId | fnName | args | retCode | result
${'op'} | ${'identity'} | ${[]} | ${0} | ${{}}
${'op'} | ${'identity'} | ${[1]} | ${0} | ${1}
${'op'} | ${'identity'} | ${[1, 2]} | ${1} | ${'identity accepts up to 1 arguments, received 2 arguments'}
${'op'} | ${'noop'} | ${[1, 2]} | ${0} | ${{}}
${'op'} | ${'array'} | ${[1, 2, 3]} | ${0} | ${[1, 2, 3]}
${'op'} | ${'concat'} | ${[[1, 2], [3, 4], [5, 6]]} | ${0} | ${[1, 2, 3, 4, 5, 6]}
${'op'} | ${'concat'} | ${[[1, 2]]} | ${0} | ${[1, 2]}
${'op'} | ${'concat'} | ${[]} | ${0} | ${[]}
${'op'} | ${'concat'} | ${[1, [1, 2], 1]} | ${1} | ${"All arguments of 'concat' must be arrays: arguments 0, 2 are not"}
${'op'} | ${'string_to_b58'} | ${["test"]} | ${0} | ${"3yZe7d"}
${'op'} | ${'string_to_b58'} | ${["test", 1]} | ${1} | ${"string_to_b58 accepts only one string argument"}
${'op'} | ${'string_from_b58'} | ${["3yZe7d"]} | ${0} | ${"test"}
${'op'} | ${'string_from_b58'} | ${["3yZe7d", 1]} | ${1} | ${"string_from_b58 accepts only one string argument"}
${'op'} | ${'bytes_to_b58'} | ${[[116, 101, 115, 116]]} | ${0} | ${"3yZe7d"}
${'op'} | ${'bytes_to_b58'} | ${[[116, 101, 115, 116], 1]} | ${1} | ${"bytes_to_b58 accepts only single argument: array of numbers"}
${'op'} | ${'bytes_from_b58'} | ${["3yZe7d"]} | ${0} | ${[116, 101, 115, 116]}
${'op'} | ${'bytes_from_b58'} | ${["3yZe7d", 1]} | ${1} | ${"bytes_from_b58 accepts only one string argument"}
${'peer'} | ${'timeout'} | ${[200, []]} | ${0} | ${[]}}
${'peer'} | ${'timeout'} | ${[200, ['test']]} | ${0} | ${['test']}}
${'peer'} | ${'timeout'} | ${[]} | ${1} | ${'timeout accepts exactly two arguments: timeout duration in ms and a message string'}}
${'peer'} | ${'timeout'} | ${[200, 'test', 1]} | ${1} | ${'timeout accepts exactly two arguments: timeout duration in ms and a message string'}}
${'sig'} | ${'verify'} | ${[testData, testDataSig]} | ${0} | ${true}}
${'sig'} | ${'verify'} | ${[testData, testDataWrongSig]} | ${0} | ${false}}
${'sig'} | ${'sign'} | ${[]} | ${1} | ${'sign accepts exactly one argument: data be signed in format of u8 array of bytes'}}
${'sig'} | ${'verify'} | ${[testData]} | ${1} | ${'verify accepts exactly two arguments: data and signature, both in format of u8 array of bytes'}}
`.test(
//
'$fnName with $args expected retcode: $retCode and result: $result',
@ -56,7 +90,7 @@ describe('Tests for default handler', () => {
};
// act
const fn = defaultServices[req.serviceId][req.fnName];
const fn = builtInServices(await context)[req.serviceId][req.fnName];
const res = await fn(req);
// assert
@ -84,7 +118,7 @@ describe('Tests for default handler', () => {
};
// act
const fn = defaultServices[req.serviceId][req.fnName];
const fn = builtInServices(await context)[req.serviceId][req.fnName];
const res = await fn(req);
// assert
@ -93,4 +127,166 @@ describe('Tests for default handler', () => {
result: 'The JS implementation of Peer does not support identify',
});
});
it('sig.sign should create the correct signature', async () => {
// arrange
const ctx = await context;
const req: CallServiceData = {
serviceId: 'sig',
fnName: 'sign',
args: [testData],
tetraplets: [
[
{
function_name: 'get_trust_bytes',
json_path: '',
peer_pk: '',
service_id: 'trust-graph',
},
],
],
particleContext: {
particleId: 'some',
initPeerId: ctx.peerId,
timestamp: 595951200,
ttl: 595961200,
signature: 'sig',
},
};
// act
const fn = builtInServices(ctx)[req.serviceId][req.fnName];
const res = await fn(req);
// assert
expect(res).toMatchObject({
retCode: 0,
result: testDataSig,
});
});
it('sign-verify call chain should work', async () => {
const ctx = await context;
const signReq: CallServiceData = {
serviceId: 'sig',
fnName: 'sign',
args: [testData],
tetraplets: [
[
{
function_name: 'get_trust_bytes',
json_path: '',
peer_pk: '',
service_id: 'trust-graph',
},
],
],
particleContext: {
particleId: 'some',
initPeerId: ctx.peerId,
timestamp: 595951200,
ttl: 595961200,
signature: 'sig',
},
};
const signFn = builtInServices(ctx)[signReq.serviceId][signReq.fnName];
const signRes = await signFn(signReq);
const verifyReq: CallServiceData = {
serviceId: 'sig',
fnName: 'verify',
args: [testData, signRes.result],
tetraplets: [],
particleContext: {
particleId: 'some',
initPeerId: ctx.peerId,
timestamp: 595951200,
ttl: 595961200,
signature: 'sig',
},
};
const verifyFn = builtInServices(ctx)[verifyReq.serviceId][verifyReq.fnName];
const verifyRes = await verifyFn(verifyReq);
expect(verifyRes).toMatchObject({
retCode: 0,
result: true,
});
});
it('sig.sign should not allow data from incorrect services', async () => {
// arrange
const ctx = await context;
const req: CallServiceData = {
serviceId: 'sig',
fnName: 'sign',
args: [testData],
tetraplets: [
[
{
function_name: 'some-other-fn',
json_path: '',
peer_pk: '',
service_id: 'cool-service',
},
],
],
particleContext: {
particleId: 'some',
initPeerId: ctx.peerId,
timestamp: 595951200,
ttl: 595961200,
signature: 'sig',
},
};
// act
const fn = builtInServices(ctx)[req.serviceId][req.fnName];
const res = await fn(req);
// assert
expect(res).toMatchObject({
retCode: 1,
result: expect.stringContaining("Only data from the following services is allowed to be signed:"),
});
});
it('sig.sign should not allow particles initiated from other peers', async () => {
// arrange
const ctx = await context;
const req: CallServiceData = {
serviceId: 'sig',
fnName: 'sign',
args: [testData],
tetraplets: [
[
{
function_name: 'some-other-fn',
json_path: '',
peer_pk: '',
service_id: 'cool-service',
},
],
],
particleContext: {
particleId: 'some',
initPeerId: (await KeyPair.randomEd25519()).Libp2pPeerId.toB58String(),
timestamp: 595951200,
ttl: 595961200,
signature: 'sig',
},
};
// act
const fn = builtInServices(ctx)[req.serviceId][req.fnName];
const res = await fn(req);
// assert
expect(res).toMatchObject({
retCode: 1,
result: 'sign is only allowed to be called on the same peer the particle was initiated from',
});
});
});

View File

@ -33,7 +33,7 @@ import { createInterpreter, dataToString } from './utils';
import { filter, pipe, Subject, tap } from 'rxjs';
import { RequestFlow } from './compilerSupport/v1';
import log from 'loglevel';
import { defaultServices } from './defaultServices';
import { BuiltInServiceContext, builtInServices } from './builtInServices';
import { instanceOf } from 'ts-pattern';
/**
@ -210,7 +210,10 @@ export class FluencePeer {
}
this._legacyCallServiceHandler = new LegacyCallServiceHandler();
registerDefaultServices(this);
registerDefaultServices(this, {
peerKeyPair: this._keyPair,
peerId: this.getStatus().peerId,
});
this._startParticleProcessing();
}
@ -459,7 +462,7 @@ export class FluencePeer {
this._execSingleCallRequest(req)
.catch(
(err): CallServiceResult => ({
retCode: ResultCodes.exceptionInHandler,
retCode: ResultCodes.error,
result: `Handler failed. fnName="${req.fnName}" serviceId="${
req.serviceId
}" error: ${err.toString()}`,
@ -529,7 +532,7 @@ export class FluencePeer {
res = handler
? await handler(req)
: {
retCode: ResultCodes.unknownError,
retCode: ResultCodes.error,
result: `No handler has been registered for serviceId='${req.serviceId}' fnName='${req.fnName}' args='${req.args}'`,
};
}
@ -576,10 +579,11 @@ function serviceFnKey(serviceId: string, fnName: string) {
return `${serviceId}/${fnName}`;
}
function registerDefaultServices(peer: FluencePeer) {
for (let serviceId in defaultServices) {
for (let fnName in defaultServices[serviceId]) {
const h = defaultServices[serviceId][fnName];
function registerDefaultServices(peer: FluencePeer, context: BuiltInServiceContext) {
const ctx = builtInServices(context);
for (let serviceId in ctx) {
for (let fnName in ctx[serviceId]) {
const h = ctx[serviceId][fnName];
peer.internals.regHandler.common(serviceId, fnName, h);
}
}

View File

@ -54,4 +54,12 @@ export class KeyPair {
toEd25519PrivateKey(): Uint8Array {
return this.Libp2pPeerId.privKey.marshal().subarray(0, 32);
}
signBytes(data: Uint8Array): Promise<Uint8Array> {
return this.Libp2pPeerId.privKey.sign(data);
}
verify(data: Uint8Array, signature: Uint8Array): Promise<boolean> {
return this.Libp2pPeerId.privKey.public.verify(data, signature);
}
}

View File

@ -0,0 +1,175 @@
/*
* Copyright 2021 Fluence Labs Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CallServiceResult } from '@fluencelabs/avm';
import { encode, decode } from 'bs58';
import { PeerIdB58 } from 'src';
import { GenericCallServiceHandler, ResultCodes } from './commonTypes';
import { KeyPair } from './KeyPair';
const success = (result: any): CallServiceResult => {
return {
result: result,
retCode: ResultCodes.success,
};
};
const error = (error: string): CallServiceResult => {
return {
result: error,
retCode: ResultCodes.error,
};
};
export interface BuiltInServiceContext {
peerKeyPair: KeyPair;
peerId: PeerIdB58;
}
export function builtInServices(context: BuiltInServiceContext): {
[serviceId in string]: { [fnName in string]: GenericCallServiceHandler };
} {
return {
op: {
noop: (req) => {
return success({});
},
array: (req) => {
return success(req.args);
},
identity: (req) => {
if (req.args.length > 1) {
return error(`identity accepts up to 1 arguments, received ${req.args.length} arguments`);
} else {
return success(req.args.length === 0 ? {} : req.args[0]);
}
},
concat: (req) => {
const incorrectArgIndices = req.args //
.map((x, i) => [Array.isArray(x), i])
.filter(([isArray, _]) => !isArray)
.map(([_, index]) => index);
if (incorrectArgIndices.length > 0) {
const str = incorrectArgIndices.join(', ');
return error(`All arguments of 'concat' must be arrays: arguments ${str} are not`);
} else {
return success([].concat.apply([], req.args));
}
},
string_to_b58: (req) => {
if (req.args.length !== 1) {
return error('string_to_b58 accepts only one string argument');
} else {
return success(encode(new TextEncoder().encode(req.args[0])));
}
},
string_from_b58: (req) => {
if (req.args.length !== 1) {
return error('string_from_b58 accepts only one string argument');
} else {
return success(new TextDecoder().decode(decode(req.args[0])));
}
},
bytes_to_b58: (req) => {
if (req.args.length !== 1 || !Array.isArray(req.args[0])) {
return error('bytes_to_b58 accepts only single argument: array of numbers');
} else {
const argumentArray = req.args[0] as number[];
return success(encode(new Uint8Array(argumentArray)));
}
},
bytes_from_b58: (req) => {
if (req.args.length !== 1) {
return error('bytes_from_b58 accepts only one string argument');
} else {
return success(Array.from(decode(req.args[0])));
}
},
},
peer: {
timeout: (req) => {
if (req.args.length !== 2) {
return error('timeout accepts exactly two arguments: timeout duration in ms and a message string');
}
const durationMs = req.args[0];
const message = req.args[1];
return new Promise((resolve) => {
setTimeout(() => {
const res = success(message);
resolve(res);
}, durationMs);
});
},
identify: (req) => {
return error('The JS implementation of Peer does not support identify');
},
},
sig: {
sign: async (req) => {
if (req.args.length !== 1) {
return error('sign accepts exactly one argument: data be signed in format of u8 array of bytes');
}
if (req.particleContext.initPeerId !== context.peerId) {
return error('sign is only allowed to be called on the same peer the particle was initiated from');
}
const t = req.tetraplets[0][0];
const serviceFnPair = `${t.service_id}.${t.function_name}`;
const allowedServices = [
'trust-graph.get_trust_bytes',
'trust-graph.get_revocation_bytes',
'registry.get_key_bytes',
'registry.get_record_bytes',
];
if (allowedServices.indexOf(serviceFnPair) === -1) {
return error(
'Only data from the following services is allowed to be signed: ' + allowedServices.join(', '),
);
}
const [data] = req.args;
const signedData = await context.peerKeyPair.signBytes(Uint8Array.from(data));
return success(Array.from(signedData));
},
verify: async (req) => {
if (req.args.length !== 2) {
return error(
'verify accepts exactly two arguments: data and signature, both in format of u8 array of bytes',
);
}
const [data, signature] = req.args;
const result = await context.peerKeyPair.verify(Uint8Array.from(data), Uint8Array.from(signature));
return success(result);
},
},
};
}

View File

@ -59,8 +59,7 @@ export interface CallParams<ArgName extends string | null> {
export enum ResultCodes {
success = 0,
unknownError = 1,
exceptionInHandler = 2,
error = 1,
}
/**

View File

@ -37,7 +37,7 @@ export const callLegacyCallServiceHandler = (
if (res.retCode === undefined) {
res = {
retCode: ResultCodes.unknownError,
retCode: ResultCodes.error,
result: `The handler did not set any result. Make sure you are calling the right peer and the handler has been registered. Original request data was: serviceId='${req.serviceId}' fnName='${req.fnName}' args='${req.args}'`,
};
}

View File

@ -1,121 +0,0 @@
/*
* Copyright 2021 Fluence Labs Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CallServiceResult } from '@fluencelabs/avm';
import { encode, decode } from 'bs58';
import { GenericCallServiceHandler, ResultCodes } from './commonTypes';
const success = (result: any): CallServiceResult => {
return {
result: result,
retCode: ResultCodes.success,
};
};
const error = (error: string): CallServiceResult => {
return {
result: error,
retCode: ResultCodes.unknownError,
};
};
export const defaultServices: { [serviceId in string]: { [fnName in string]: GenericCallServiceHandler } } = {
op: {
noop: (req) => {
return success({});
},
array: (req) => {
return success(req.args);
},
identity: (req) => {
if (req.args.length > 1) {
return error(`identity accepts up to 1 arguments, received ${req.args.length} arguments`);
} else {
return success(req.args.length === 0 ? {} : req.args[0]);
}
},
concat: (req) => {
const incorrectArgIndices = req.args //
.map((x, i) => [Array.isArray(x), i])
.filter(([isArray, _]) => !isArray)
.map(([_, index]) => index);
if (incorrectArgIndices.length > 0) {
const str = incorrectArgIndices.join(', ');
return error(`All arguments of 'concat' must be arrays: arguments ${str} are not`);
} else {
return success([].concat.apply([], req.args));
}
},
string_to_b58: (req) => {
if (req.args.length !== 1) {
return error('string_to_b58 accepts only one string argument');
} else {
return success(encode(new TextEncoder().encode(req.args[0])));
}
},
string_from_b58: (req) => {
if (req.args.length !== 1) {
return error('string_from_b58 accepts only one string argument');
} else {
return success(new TextDecoder().decode(decode(req.args[0])));
}
},
bytes_to_b58: (req) => {
if (req.args.length !== 1 || !Array.isArray(req.args[0])) {
return error('bytes_to_b58 accepts only single argument: array of numbers');
} else {
const argumentArray = req.args[0] as number[];
return success(encode(new Uint8Array(argumentArray)));
}
},
bytes_from_b58: (req) => {
if (req.args.length !== 1) {
return error('bytes_from_b58 accepts only one string argument');
} else {
return success(Array.from(decode(req.args[0])));
}
},
},
peer: {
timeout: (req) => {
if (req.args.length !== 2) {
return error('timeout accepts exactly two arguments: timeout duration in ms and a message string');
}
const durationMs = req.args[0];
const message = req.args[1];
return new Promise((resolve) => {
setTimeout(() => {
const res = success(message);
resolve(res);
}, durationMs);
});
},
identify: (req) => {
return error('The JS implementation of Peer does not support identify');
},
},
};